From 70ee8d9ece65ef2111709c9fd5154d4c5571e3fd Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Sat, 24 Oct 2026 13:33:28 +0300 Subject: [PATCH 1/3] =?UTF-8?q?Render:=20=D1=81=D0=BF=D0=BB=D0=BE=D1=88?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=B3=D0=BB=D0=B8=D1=84=20=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B2=D0=BA=D0=B0=20=D0=BF=D1=80=D1=8F=D0=BC?= =?UTF-8?q?=D0=BE=D1=83=D0=B3=D0=BE=D0=BB=D1=8C=D0=BD=D0=B8=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D0=B2=D0=B5=D1=80=D0=BB?= =?UTF-8?q?=D0=B5=D0=B5=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/render/src/text.rs | 45 +++++++++++++++++++++++++++-- crates/render/src/world_renderer.rs | 21 +++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index b37c8c6..dce01ee 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -30,7 +30,7 @@ use font8x8::UnicodeFonts; /// Строит атлас глифов ASCII 32..127 (R8, альфа). pub fn build_atlas() -> Vec { let mut atlas = vec![0u8; (ATLAS_W * ATLAS_H) as usize]; - for code in 32u8..127 { + for code in 32u8..=127 { let glyph = font8x8::BASIC_FONTS .get(char::from(code)) .unwrap_or([0u8; 8]); @@ -47,14 +47,39 @@ pub fn build_atlas() -> Vec { } } } + // 127 — служебный «белый квадрат»: им заливаются прямоугольники (фон + // меню/затемнение), у шрифта такого символа нет. + let index = 127u32 - 32; + let gx = (index % 16) * 8; + let gy = (index / 16) * 8; + for row in 0..8u32 { + for col in 0..8u32 { + atlas[((gy + row) * ATLAS_W + gx + col) as usize] = 255; + } + } atlas } /// UV-прямоугольник глифа в атласе. pub fn glyph_uv(code: u8) -> ([f32; 2], [f32; 2]) { - let index = code.clamp(32, 126) as u32 - 32; + let index = code.clamp(32, 127) as u32 - 32; let gx = (index % 16) * 8; let gy = (index / 16) * 8; + // Сплошной глиф растягивается на весь экран: берём UV с отступом в + // полтексела, иначе линейная фильтрация подмешивает соседний глиф и на + // границе заливки появляется полоса. + if code == 127 { + return ( + [ + (gx as f32 + 0.5) / ATLAS_W as f32, + (gy as f32 + 0.5) / ATLAS_H as f32, + ], + [ + (gx as f32 + 7.5) / ATLAS_W as f32, + (gy as f32 + 7.5) / ATLAS_H as f32, + ], + ); + } ( [gx as f32 / ATLAS_W as f32, gy as f32 / ATLAS_H as f32], [ @@ -110,6 +135,22 @@ mod tests { assert!(lit > 100, "атлас пуст: {lit}"); } + /// Служебный глиф 127 — сплошной белый квадрат для заливки фона. + #[test] + fn atlas_has_solid_glyph() { + let atlas = build_atlas(); + let (uv0, uv1) = glyph_uv(127); + let x0 = (uv0[0] * ATLAS_W as f32) as u32; + let y0 = (uv0[1] * ATLAS_H as f32) as u32; + let x1 = (uv1[0] * ATLAS_W as f32) as u32; + let y1 = (uv1[1] * ATLAS_H as f32) as u32; + for y in y0..y1 { + for x in x0..x1 { + assert_eq!(atlas[(y * ATLAS_W + x) as usize], 255, "пиксель {x},{y}"); + } + } + } + #[test] fn vertices_for_text() { let items = vec![TextItem { diff --git a/crates/render/src/world_renderer.rs b/crates/render/src/world_renderer.rs index 0539037..52d45a5 100644 --- a/crates/render/src/world_renderer.rs +++ b/crates/render/src/world_renderer.rs @@ -1278,7 +1278,7 @@ impl WorldRenderer { resource: screen_buffer.as_entire_binding(), }], }); - let capacity = 16384; + let capacity = 65536; let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("text vertices"), size: (capacity * std::mem::size_of::()) as u64, @@ -1465,6 +1465,25 @@ 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()); + } + } + /// Загружает оверлей на GPU (размер экрана + вершины). fn upload_text(&self, queue: &wgpu::Queue, width: u32, height: u32) { let screen: [f32; 4] = [width as f32, height as f32, 0.0, 0.0]; From f41bfde6908cf65113dd9574dceb4bac816ea518 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Sat, 24 Oct 2026 13:33:34 +0300 Subject: [PATCH 2/3] =?UTF-8?q?Lua:=20=D0=BF=D0=BE=D0=BB=D1=8F=20ENT-?= =?UTF-8?q?=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C-=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B2=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=81=D0=BF=D0=B0=D0=B2=D0=BD-=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/lua/src/lib.rs | 132 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/crates/lua/src/lib.rs b/crates/lua/src/lib.rs index ac63aaa..5bf4a79 100644 --- a/crates/lua/src/lib.rs +++ b/crates/lua/src/lib.rs @@ -113,6 +113,40 @@ impl LuaRuntime { } } + /// Строковое поле ENT-скрипта (`Model`, `WorldModel`, `PrintName`, ...). + /// + /// Нужен спавн-меню: список `SpawnableEntities` хранит только имена, а + /// модель сущности лежит в её таблице. Как и у оружия, `Model("...")` + /// приходит таблицей с полем `path`. + pub fn entity_string(&self, class: &str, field: &str) -> Option { + let globals = self.lua.globals(); + let store: Table = globals.get("__sr_ents").ok()?; + let Value::Table(ent) = store.get::(class).ok()? else { + return None; + }; + match ent.get::(field) { + Ok(Value::String(value)) => Some(value.to_string_lossy().to_string()), + Ok(Value::Table(value)) => value.get::>("path").ok().flatten(), + _ => None, + } + } + + /// Булево поле ENT-скрипта (в спавн-меню — `Spawnable`). + /// + /// `nil` и не-булевы значения дают `None`: фильтр меню отличает + /// «явно false» от «поле не задано». + pub fn entity_bool(&self, class: &str, field: &str) -> Option { + let globals = self.lua.globals(); + let store: Table = globals.get("__sr_ents").ok()?; + let Value::Table(ent) = store.get::(class).ok()? else { + return None; + }; + match ent.get::(field) { + Ok(Value::Boolean(value)) => Some(value), + _ => None, + } + } + /// Загружает скрипты GMod: `lua/weapons/*.lua` и `lua/entities/*.lua`. /// /// Каждый скрипт выполняется со свежей таблицей `SWEP`/`ENT` (у оружия @@ -166,6 +200,16 @@ impl LuaRuntime { failed += 1; continue; } + // Модель спавн-меню: у ENT-скриптов её часто задают только в + // коде (`self:SetModel("models/...")`), а не полем `ENT.Model`. + // Запоминаем первый литерал `models/*.mdl` — он и будет + // представителем сущности в списке спавна. + if kind == "entity" + && let Some(model) = find_model_literal(&source) + && let Err(err) = table.set("__sr_preview_model", model) + { + eprintln!("скрипт {}: модель для спавна: {err}", file.display()); + } // GMod-синтаксис (!, &&, ||) приводится препроцессором — // без него скрипты падают на первой же строке с `!`. if let Err(err) = self.run(name, source.as_bytes()) { @@ -742,6 +786,47 @@ pub fn preprocess(source: &str) -> String { out } +/// Первый литерал `models/*.mdl` в исходнике ENT-скрипта. +/// +/// Спавн-меню нужно чем-то показать сущность, у которой модель задаётся +/// вызовом `SetModel` в `Initialize`, а не полем `ENT.Model`. Поиск +/// ограничен: путь короче 256 байт, только до кавычки/пробела/закрывающей +/// скобки — данные из файла не должны влиять на границы среза. +fn find_model_literal(source: &str) -> Option { + const PREFIX: &[u8] = b"models/"; + const MAX_PATH: usize = 256; + let bytes = source.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + if i + PREFIX.len() <= bytes.len() + && bytes[i..i + PREFIX.len()].eq_ignore_ascii_case(PREFIX) + { + let mut end = i + PREFIX.len(); + while end < bytes.len() && end - i < MAX_PATH { + let b = bytes[end]; + if b.is_ascii_whitespace() || matches!(b, b'"' | b'\'' | b')' | b',' | b';' | 0) { + break; + } + end += 1; + } + let Some(candidate) = source.get(i..end) else { + // Лимит длины мог разрезать многобайтовый символ: пропускаем. + i += 1; + continue; + }; + if candidate.len() > PREFIX.len() + && candidate + .get(candidate.len().saturating_sub(4)..) + .is_some_and(|ext| ext.eq_ignore_ascii_case(".mdl")) + { + return Some(candidate.to_string()); + } + } + i += 1; + } + None +} + /// Если на позиции длинная скобка `[=*[`, возвращает её длину. fn long_bracket(bytes: &[u8], at: usize) -> (usize, ()) { if bytes.get(at) != Some(&b'[') { @@ -889,4 +974,51 @@ local t = [[raw ! text]] assert_eq!(out[4], "5 0 0", "поле записывается"); assert_eq!(out[5], "10", "math.Clamp"); } + + /// Реестр ENT-скриптов отдаёт поля, по которым строится спавн-меню: + /// класс, `Spawnable` и модель. + #[test] + fn entity_registry_exposes_spawn_fields() { + let rt = LuaRuntime::new().expect("рантайм"); + rt.run( + "sent_test.lua", + br#" + local ENT = { + Type = "sent_test", + Spawnable = true, + Model = Model("models/props_junk/watermelon01.mdl"), + } + scripted_ents.Register(ENT, "sent_test") + "#, + ) + .expect("скрипт"); + assert_eq!( + rt.entity_string("sent_test", "Model").as_deref(), + Some("models/props_junk/watermelon01.mdl") + ); + assert_eq!(rt.entity_bool("sent_test", "Spawnable"), Some(true)); + assert_eq!(rt.entity_bool("sent_test", "AdminOnly"), None); + assert_eq!(rt.entity_string("нет_такого", "Model"), None); + } + + /// Модель `SetModel` в коде ENT-скрипта находится без разбора Lua: + /// только литерал, с границами и лимитом длины. + #[test] + fn model_literal_from_entity_source() { + let src = r#"function ENT:Initialize() self:SetModel( "models/Combine_Helicopter/helicopter_bomb01.mdl" ) end"#; + assert_eq!( + find_model_literal(src).as_deref(), + Some("models/Combine_Helicopter/helicopter_bomb01.mdl") + ); + assert_eq!(find_model_literal("no models here"), None); + // Обрывок без расширения не считается моделью. + assert_eq!( + find_model_literal("SetModel('models/props_junk/a b.mdl')"), + None + ); + assert_eq!( + find_model_literal("prefab = 'models/props_junk/popcan01a.mdl'"), + Some("models/props_junk/popcan01a.mdl".to_string()) + ); + } } From 75cfbba057cb41845681781b3e05e7f5df847ed1 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Sat, 24 Oct 2026 13:33:39 +0300 Subject: [PATCH 3/3] =?UTF-8?q?Q-=D0=BC=D0=B5=D0=BD=D1=8E=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B0=D0=B2=D0=BD=D0=B0:=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=B8=D0=B7=20Lua=20=D0=B8=20spawnlist,=20=D1=81?= =?UTF-8?q?=D0=B5=D1=82=D0=BA=D0=B0,=20=D1=81=D0=BF=D0=B0=D0=B2=D0=BD=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=BB=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/client/src/lib.rs | 1 + crates/client/src/map.rs | 343 ++++++++++---- crates/client/src/qmenu.rs | 887 +++++++++++++++++++++++++++++++++++++ docs/DIARY.md | 32 +- 4 files changed, 1170 insertions(+), 93 deletions(-) create mode 100644 crates/client/src/qmenu.rs diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index e1f768e..5f4b8ee 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -9,6 +9,7 @@ mod menu; #[cfg(not(target_os = "android"))] mod net_cmd; mod pack; +mod qmenu; mod touch; use clap::{Parser, Subcommand}; diff --git a/crates/client/src/map.rs b/crates/client/src/map.rs index 7c6e821..9367201 100644 --- a/crates/client/src/map.rs +++ b/crates/client/src/map.rs @@ -1,6 +1,7 @@ //! Команда `map`: загрузка карты и рендер с полётом камеры. use crate::doctor; +use crate::qmenu::{self, MenuAction, SpawnItem, SpawnMenu}; use crate::touch::TouchControls; use anyhow::Context; use engine::FlyCamera; @@ -45,6 +46,13 @@ fn upload_debug_collision( } use tracing::{debug, info, warn}; +/// Запрос на спавн пропа из меню или клавиши G. +struct SpawnRequest { + model: String, + origin: math::Vec3, + angles: math::Vec3, +} + /// Аргументы команды `map`. #[derive(Debug, clap::Args)] pub struct MapArgs { @@ -541,7 +549,28 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } } - // Время для процедурной пробы скиннинга вьюмодели (A26). + // Q-меню спавна (A24): предметы из реестров Lua и spawnlist GMod. + // Собирается один раз на старте: списки меняются только загрузкой карты. + let mut spawn_menu = SpawnMenu::collect(lua.as_ref(), &vfs); + if 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() + && let Some(item) = spawn_menu.selected_item() + { + pending_spawns.push(menu_spawn_request(item, &collision, &player, camera.yaw)); + } + if std::env::var_os("SOURCE_RS_QMENU").is_some() { + spawn_menu.set_open(true); + player.velocity = math::Vec3::ZERO; + apply_menu_mouse(true, &sdl, &window, relative_mouse); + } + let mut wave_time = 0.0f32; // Физган: тело, которое держим, и расстояние удержания (A21/A23). // Тело, расстояние удержания и углы: колесо мыши крутит взятый проп. @@ -593,48 +622,99 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { Event::Quit { .. } => break 'running, Event::KeyDown { keycode: Some(Keycode::Escape), - .. - } => break 'running, - Event::MouseButtonDown { - mouse_btn: MouseButton::Left, + repeat: false, .. } => { - // Физган: берём ближайшее тело под прицелом — по - // расстоянию вдоль луча, с допуском на толщину пропа. - let origin = player.eye_position(); - let dir = camera.forward(); - let w = world.borrow(); - let mut best: Option<(f32, engine::physics::BodyId)> = None; - for id in w.physics.body_list() { - let Some((position, _)) = w.physics.transform(id) else { - continue; - }; - let to_body = position - origin; - let along = to_body.dot(dir); - if !(8.0..=260.0).contains(&along) { - continue; - } - let perpendicular = (to_body - dir * along).length(); - if perpendicular > 40.0 { - continue; - } - if best.is_none_or(|(best_along, _)| along < best_along) { - best = Some((along, id)); + // Esc в открытом меню закрывает его, а не клиент. + if spawn_menu.is_open() { + close_menu(&mut spawn_menu, &sdl, &window, relative_mouse); + } else { + break 'running; + } + } + Event::KeyDown { + keycode: Some(Keycode::Q), + repeat: false, + .. + } => { + if spawn_menu.is_open() { + close_menu(&mut spawn_menu, &sdl, &window, relative_mouse); + } else { + spawn_menu.set_open(true); + info!(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()); + while log.len() > 8 { + log.pop_front(); } } - if let Some((along, id)) = best { - grabbed = Some((id, along, math::Vec3::ZERO)); - log.push_back(format!("физган: взял ({} юнитов)", along as i32)); - while log.len() > 8 { - log.pop_front(); + } + 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::Spawn(item) => { + pending_spawns + .push(menu_spawn_request(&item, &collision, &player, camera.yaw)); + } + MenuAction::None => {} + }, + Event::MouseButtonDown { + mouse_btn: MouseButton::Left, + x, + y, + .. + } => { + if spawn_menu.is_open() { + let (width, height) = gfx.size(); + if let Some(item) = spawn_menu.click(x, y, width as f32, height as f32) { + pending_spawns + .push(menu_spawn_request(&item, &collision, &player, camera.yaw)); } - } else if view_fire_animation.is_some() { - // Под прицелом нет тела — ЛКМ стреляет: одноразовая - // анимация вьюмодели, без оружия и урона (A23). - view_fire_time = Some(0.0); - log.push_back("выстрел".to_string()); - while log.len() > 8 { - log.pop_front(); + } else { + // Физган: берём ближайшее тело под прицелом — по + // расстоянию вдоль луча, с допуском на толщину пропа. + let origin = player.eye_position(); + let dir = camera.forward(); + let w = world.borrow(); + let mut best: Option<(f32, engine::physics::BodyId)> = None; + for id in w.physics.body_list() { + let Some((position, _)) = w.physics.transform(id) else { + continue; + }; + let to_body = position - origin; + let along = to_body.dot(dir); + if !(8.0..=260.0).contains(&along) { + continue; + } + let perpendicular = (to_body - dir * along).length(); + if perpendicular > 40.0 { + continue; + } + if best.is_none_or(|(best_along, _)| along < best_along) { + best = Some((along, id)); + } + } + if let Some((along, id)) = best { + grabbed = Some((id, along, math::Vec3::ZERO)); + log.push_back(format!("физган: взял ({} юнитов)", along as i32)); + while log.len() > 8 { + log.pop_front(); + } + } else if view_fire_animation.is_some() { + // Под прицелом нет тела — ЛКМ стреляет: одноразовая + // анимация вьюмодели, без оружия и урона (A23). + view_fire_time = Some(0.0); + log.push_back("выстрел".to_string()); + while log.len() > 8 { + log.pop_front(); + } } } } @@ -642,7 +722,8 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { mouse_btn: MouseButton::Left, .. } => { - if grabbed.take().is_some() { + // В меню отпускание кнопки не относится к физгану. + if !spawn_menu.is_open() && grabbed.take().is_some() { log.push_back("физган: отпустил".to_string()); while log.len() > 8 { log.pop_front(); @@ -654,51 +735,18 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { repeat: false, .. } => { - // Спавн пропа по взгляду — первый шаг к Q-меню (A24). + // Спавн пропа по взгляду — быстрый путь без меню (A24). // Модель задаётся `SOURCE_RS_SPAWN_MODEL`, по умолчанию // классический арбуз Sandbox. let model = std::env::var("SOURCE_RS_SPAWN_MODEL") .unwrap_or_else(|_| "models/props_junk/watermelon01.mdl".to_string()); let origin = player.eye_position() + camera.forward() * 96.0; let angles = math::Vec3::new(0.0, camera.yaw, 0.0); - let mut budget = usize::MAX; - let spawned = world.borrow_mut().spawn_prop( - &vfs, - &model, + pending_spawns.push(SpawnRequest { + model, origin, angles, - 10.0, - &mut budget, - ); - match spawned { - Some(index) => { - info!(index, model = %model, "проп заспавнен"); - log.push_back(format!("spawn: {model}")); - // Геометрия нового пропа уходит на GPU диапазонами - // (`moved_ranges`), поэтому пересобирать рендерер - // нужно только если появилась НОВАЯ текстура: - // иначе батч пропа не найдёт bind group материала. - let materials = world.borrow().materials.len(); - if materials > known_materials { - match WorldRenderer::new( - &gfx, - &world.borrow(), - args.debug_materials, - ) { - Ok(fresh) => renderer = fresh, - Err(err) => warn!("рендерер не пересобран: {err:#}"), - } - known_materials = materials; - } - } - None => { - warn!(model = %model, "проп не заспавнен"); - log.push_back(format!("spawn не удался: {model}")); - } - } - while log.len() > 8 { - log.pop_front(); - } + }); } Event::KeyDown { keycode: Some(Keycode::V), @@ -736,12 +784,20 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { pressed.remove(&code); } Event::MouseWheel { y, .. } => { - // Колесо крутит взятый проп вокруг вертикальной оси. - if let Some((id, along, angles)) = grabbed { + if spawn_menu.is_open() { + // Колесо листает страницы списка (он длинный). + spawn_menu.scroll(if y > 0.0 { -1 } else { 1 }); + } else if let Some((id, along, angles)) = grabbed { + // Иначе колесо крутит взятый проп вокруг вертикальной оси. let angles = math::Vec3::new(angles.x, angles.y + y * 15.0, angles.z); grabbed = Some((id, along, angles)); } } + Event::MouseMotion { x, y, .. } if spawn_menu.is_open() => { + // В открытом меню мышь управляет выбором, а не камерой. + let (width, height) = gfx.size(); + spawn_menu.hover(x, y, width as f32, height as f32); + } Event::MouseMotion { xrel, yrel, .. } if relative_mouse => { // Source: +Y — влево, поэтому движение мыши вправо уменьшает yaw. camera.rotate(-xrel * 0.15, yrel * 0.15); @@ -759,7 +815,9 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { dy, .. } => { - if let Some((yaw, pitch)) = touch.finger_motion(finger_id, x, y, dx, dy) { + if !spawn_menu.is_open() + && let Some((yaw, pitch)) = touch.finger_motion(finger_id, x, y, dx, dy) + { camera.rotate(yaw, pitch); } } @@ -786,6 +844,53 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } } + // Спавн из меню или по G — один путь: физика, коллизия и пересборка + // рендерера одинаковы, поэтому запрос копится в событиях и + // обрабатывается здесь, до шага физики. + if !pending_spawns.is_empty() { + for request in pending_spawns.drain(..) { + let mut budget = usize::MAX; + let spawned = world.borrow_mut().spawn_prop( + &vfs, + &request.model, + request.origin, + request.angles, + 10.0, + &mut budget, + ); + match spawned { + Some(index) => { + info!( + index, + model = %request.model, + origin = ?request.origin, + "проп заспавнен" + ); + log.push_back(format!("spawn: {}", request.model)); + // Геометрия нового пропа уходит на GPU диапазонами + // (`moved_ranges`), поэтому пересобирать рендерер нужно + // только если появилась НОВАЯ текстура: иначе батч пропа + // не найдёт bind group материала. + let materials = world.borrow().materials.len(); + if materials > known_materials { + match WorldRenderer::new(&gfx, &world.borrow(), args.debug_materials) { + Ok(fresh) => renderer = fresh, + Err(err) => warn!("рендерер не пересобран: {err:#}"), + } + known_materials = materials; + } + } + None => { + warn!(model = %request.model, "проп не заспавнен"); + log.push_back(format!("spawn не удался: {}", request.model)); + } + } + } + while log.len() > 8 { + log.pop_front(); + } + } + let dt = last.elapsed().as_secs_f32().min(0.1); last = Instant::now(); if log_fps && frames_drawn >= 30 { @@ -822,7 +927,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() { + if auto_grab && grabbed.is_none() && !spawn_menu.is_open() { let origin = player.eye_position(); let dir = camera.forward(); let mut best: Option<(f32, engine::physics::BodyId)> = None; @@ -891,7 +996,13 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { move_up -= 1.0; } let touch_jump = touch.take_jump(); - if player.noclip { + // В открытом меню игрок стоит: мышь и клавиши принадлежат меню, + // гравитация/ходьба не должны утащить его с места за время выбора. + let menu_open = spawn_menu.is_open(); + if menu_open { + player.velocity = math::Vec3::ZERO; + player.sprint = false; + } else if player.noclip { let speed = if pressed.contains(&Keycode::LShift) { args.speed * 3.0 } else { @@ -1171,17 +1282,20 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } ), ); - 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() - } else { - "WASD move | Space jump | Ctrl duck | V noclip | ` log".to_string() - }, - ); + if !spawn_menu.is_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() + } else { + "WASD move | Space jump | Ctrl duck | V noclip | G spawn | Q menu | ` log" + .to_string() + }, + ); + } if touch_enabled { // Подписи зон: сами зоны заданы в `touch.rs`, здесь только // ориентиры, чтобы на телефоне было понятно, куда нажимать. @@ -1215,6 +1329,9 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } } + // Q-меню поверх HUD: выбор предмета и подсказки по управлению. + spawn_menu.draw(&mut renderer, width as f32, height as f32); + // Lua: таймеры и Think-хук. if let Some(lua) = &lua { let _ = lua.tick(); @@ -1394,6 +1511,48 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { Ok(()) } +/// Запрос на спавн выбранного предмета: точка на полу перед игроком. +fn menu_spawn_request( + item: &SpawnItem, + collision: &engine::Collision, + player: &engine::Player, + yaw: f32, +) -> SpawnRequest { + SpawnRequest { + model: item.model.clone(), + origin: qmenu::spawn_origin(collision, player, yaw), + angles: math::Vec3::new(0.0, yaw, 0.0), + } +} + +/// Включает/выключает захват мыши при открытии меню. +/// +/// В меню нужен обычный курсор: относительный режим SDL не даёт попасть по +/// предмету. При закрытии возвращаем прежнее поведение (если оно было). +fn apply_menu_mouse( + open: bool, + sdl: &sdl3::Sdl, + window: &sdl3::video::Window, + relative_mouse: bool, +) { + if open { + sdl.mouse().set_relative_mouse_mode(window, false); + } else if relative_mouse { + sdl.mouse().set_relative_mouse_mode(window, true); + } +} + +/// Закрывает Q-меню и возвращает захват мыши. +fn close_menu( + 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); +} + fn save_png(path: &Path, width: u32, height: u32, rgba: &[u8]) -> anyhow::Result<()> { let file = std::fs::File::create(path).with_context(|| format!("создание {}", path.display()))?; diff --git a/crates/client/src/qmenu.rs b/crates/client/src/qmenu.rs new file mode 100644 index 0000000..22f5c26 --- /dev/null +++ b/crates/client/src/qmenu.rs @@ -0,0 +1,887 @@ +//! Q-меню спавна: предметы из ассетов, сетка и точка спавна. +//! +//! Список собирается из тех же данных, что видит Sandbox: +//! - `list.Get("SpawnableEntities")` — ENT-скрипты с `Spawnable = true`; +//! - `list.Get("Weapon")` — SWEP-скрипты (`WorldModel`, иначе `ViewModel`); +//! - `settings/spawnlist*/` — KeyValues-списки моделей самого GMod. +//! +//! Каждый путь проверяется через VFS: битые и отсутствующие модели в меню не +//! попадают, иначе клик по такому предмету гарантированно ничего не спавнит. +//! UI — текст: у клиента свой bitmap-рендерер, панелей и иконок пока нет. + +use fs::VirtualFs; +use math::{Aabb, Vec3}; +use render::WorldRenderer; +use sdl3::keyboard::Keycode; +use std::collections::HashSet; + +/// Категория предмета (вкладки меню). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// ENT-скрипты с `Spawnable = true`. + Entity, + /// SWEP-скрипты. + Weapon, + /// Модели пропов из spawnlist GMod. + Prop, +} + +impl Kind { + /// Порядок вкладок: как в Sandbox — сущности, оружие, пропы. + pub const ALL: [Kind; 3] = [Self::Entity, Self::Weapon, Self::Prop]; + + fn index(self) -> usize { + match self { + Self::Entity => 0, + Self::Weapon => 1, + Self::Prop => 2, + } + } + + /// Подпись вкладки (только ASCII: bitmap-шрифт не знает Unicode). + pub fn title(self) -> &'static str { + match self { + Self::Entity => "ENTITIES", + Self::Weapon => "WEAPONS", + Self::Prop => "PROPS", + } + } +} + +/// Предмет в списке спавна. +#[derive(Debug, Clone)] +pub struct SpawnItem { + /// Подпись в сетке. + pub label: String, + /// Путь к модели (уже проверен в VFS). + pub model: String, + pub kind: Kind, +} + +/// Результат нажатия клавиши в открытом меню. +#[derive(Debug, Clone)] +pub enum MenuAction { + None, + Close, + Spawn(SpawnItem), +} + +/// Сколько предметов на странице: 4 колонки × 8 строк. +const COLS: usize = 4; +const ROWS: usize = 8; +const PAGE_SIZE: usize = COLS * ROWS; +/// Сколько моделей из spawnlist вообще рассматриваем (защита от гигантских +/// пользовательских списков: меню — не повод съесть память). +const MAX_SPAWNLIST_MODELS: usize = 20_000; + +/// Встроенный запасной список: используется, если в ассетах нет ни одного +/// живого spawnlist. Пути проверяются в VFS так же, как остальные. +const FALLBACK_PROPS: [&str; 6] = [ + "models/props_junk/watermelon01.mdl", + "models/props_c17/oildrum001.mdl", + "models/props_junk/wood_crate001a.mdl", + "models/props_c17/furnituretable001a.mdl", + "models/props_c17/furniturechair001a.mdl", + "models/props_c17/doll01.mdl", +]; + +/// Состояние Q-меню. +pub struct SpawnMenu { + items: Vec, + /// Индексы предметов каждой категории (порядок отображения). + by_kind: [Vec; 3], + kind: Kind, + /// Выбранный предмет — индекс внутри текущей категории. + selected: usize, + open: bool, + /// Предмет под курсором на текущей странице (для подсветки). + hover: Option, +} + +impl SpawnMenu { + /// Собирает список предметов из Lua-реестров и spawnlist в VFS. + pub fn collect(lua: Option<&glua::LuaRuntime>, vfs: &VirtualFs) -> Self { + let mut items: Vec = Vec::new(); + let (mut entities, mut weapons, mut props) = (0usize, 0usize, 0usize); + let (mut entity_missing, mut weapon_missing, mut prop_missing) = (0usize, 0usize, 0usize); + + if let Some(lua) = lua { + // ENT-скрипты: только явно спавнимые. `widget_*` — служебные + // сущности Sandbox, им в меню спавна делать нечего. + for class in lua.list_names("SpawnableEntities") { + if lua.entity_bool(&class, "Spawnable") != Some(true) { + continue; + } + let model = ["Model", "WorldModel", "__sr_preview_model"] + .into_iter() + .filter_map(|field| lua.entity_string(&class, field)) + .find_map(|value| clean_model_path(&value)); + let Some(model) = model else { + entity_missing += 1; + continue; + }; + if !vfs.exists(&model) { + entity_missing += 1; + continue; + } + let label = lua + .entity_string(&class, "PrintName") + .filter(|name| !name.starts_with('#')) + .unwrap_or(class); + items.push(SpawnItem { + label, + model, + kind: Kind::Entity, + }); + entities += 1; + } + + // Оружие: в мире показываем мировую модель, у `weapon_fists` её + // нет (пустая строка) — берём вьюмодель, она хотя бы существует. + for class in lua.list_names("Weapon") { + let model = ["WorldModel", "ViewModel"] + .into_iter() + .filter_map(|field| lua.weapon_string(&class, field)) + .filter(|value| !value.trim().is_empty()) + .find_map(|value| clean_model_path(&value)); + let Some(model) = model else { + weapon_missing += 1; + continue; + }; + if !vfs.exists(&model) { + weapon_missing += 1; + continue; + } + let label = lua + .weapon_string(&class, "PrintName") + .filter(|name| !name.starts_with('#')) + .unwrap_or(class); + items.push(SpawnItem { + label, + model, + kind: Kind::Weapon, + }); + weapons += 1; + } + } + + // Пропы: списки моделей GMod. Если их нет (или ни одна модель не + // нашлась в VFS) — небольшой встроенный набор, чтобы меню не пустовало. + let mut prop_models = collect_spawnlist_models(vfs); + if prop_models.is_empty() { + prop_models.extend(FALLBACK_PROPS.iter().map(|model| model.to_string())); + } + prop_models.sort_by_key(|model| model.to_ascii_lowercase()); + let mut seen: HashSet = HashSet::new(); + for model in prop_models { + let model = match clean_model_path(&model) { + Some(model) => model, + None => continue, + }; + if !model.to_ascii_lowercase().ends_with(".mdl") { + continue; + } + let key = model.to_ascii_lowercase(); + if !seen.insert(key) { + continue; + } + if !vfs.exists(&model) { + prop_missing += 1; + continue; + } + items.push(SpawnItem { + label: prop_label(&model), + model, + kind: Kind::Prop, + }); + props += 1; + } + + tracing::info!( + entities, + weapons, + props, + entity_missing, + weapon_missing, + prop_missing, + "Q-меню: список спавна собран" + ); + + let mut menu = Self::from_items(items); + // Sandbox открывает меню на «Props»; если там пусто — первая + // непустая вкладка, чтобы клик по Q не показывал пустую сетку. + menu.kind = if menu.count(Kind::Prop) > 0 { + Kind::Prop + } else { + Kind::ALL + .into_iter() + .find(|kind| menu.count(*kind) > 0) + .unwrap_or(Kind::Prop) + }; + // В Sandbox меню открывается на «Props»; конкретный предмет выбирает + // пользователь (или `SOURCE_RS_QMENU_ITEM` — для скриншотов). + menu + } + + /// Меню из готового списка (для тестов и повторной сборки). + pub fn from_items(items: Vec) -> Self { + let mut by_kind: [Vec; 3] = [Vec::new(), Vec::new(), Vec::new()]; + for (index, item) in items.iter().enumerate() { + by_kind[item.kind.index()].push(index); + } + Self { + items, + by_kind, + kind: Kind::Prop, + selected: 0, + open: false, + hover: None, + } + } + + pub fn is_open(&self) -> bool { + self.open + } + + pub fn set_open(&mut self, open: bool) { + self.open = open; + self.hover = None; + } + + pub fn kind(&self) -> Kind { + self.kind + } + + pub fn count(&self, kind: Kind) -> usize { + self.by_kind[kind.index()].len() + } + + /// Предметы текущей категории (индексы в общем списке). + fn current(&self) -> &[usize] { + &self.by_kind[self.kind.index()] + } + + pub fn selected_item(&self) -> Option<&SpawnItem> { + let index = *self.current().get(self.selected)?; + self.items.get(index) + } + + pub fn switch(&mut self, kind: Kind) { + self.kind = kind; + self.selected = 0; + self.hover = None; + } + + /// Следующая/предыдущая вкладка по кругу. + pub fn cycle(&mut self, step: i32) { + let count = Kind::ALL.len() as i32; + let index = (self.kind.index() as i32 + step).rem_euclid(count) as usize; + self.switch(Kind::ALL[index]); + } + + /// Сдвиг выбора по сетке. `dx` — колонки, `dy` — строки. + pub fn move_selection(&mut self, dx: i32, dy: i32) { + let len = self.current().len(); + if len == 0 { + self.selected = 0; + return; + } + let step = dx + dy * COLS as i32; + let next = (self.selected as i32 + step).clamp(0, len as i32 - 1); + self.selected = next as usize; + self.hover = None; + } + + /// Прокрутка страницами (колесо, PageUp/PageDown). + pub fn scroll(&mut self, pages: i32) { + let len = self.current().len(); + if len == 0 { + self.selected = 0; + return; + } + let page = + (self.page() as i32 + pages).clamp(0, self.page_count().saturating_sub(1) as i32); + self.selected = (page as usize * PAGE_SIZE).min(len - 1); + self.hover = None; + } + + /// Подсветить предмет под курсором (и заодно выбрать его). + pub fn hover(&mut self, x: f32, y: f32, width: f32, height: f32) { + self.hover = self.hit(x, y, width, height); + if let Some(index) = self.hover { + self.selected = index; + } + } + + /// Клик по сетке: выбирает предмет; `Some` — его надо заспавнить. + pub fn click(&mut self, x: f32, y: f32, width: f32, height: f32) -> Option { + let index = self.hit(x, y, width, height)?; + self.selected = index; + self.selected_item().cloned() + } + + /// Индекс предмета в текущей категории по координатам курсора. + fn hit(&self, x: f32, y: f32, width: f32, height: f32) -> Option { + let layout = Layout::new(width, height); + let cell = layout.cell_at(x, y)?; + let index = self.page() * PAGE_SIZE + cell; + (index < self.current().len()).then_some(index) + } + + /// Найти предмет по подстроке (модель или подпись) и перейти к нему. + /// + /// Отладочный вход `SOURCE_RS_QMENU_ITEM`: по нему снимаются скриншоты + /// конкретного предмета без ручного клика. + pub fn select_matching(&mut self, needle: &str) -> bool { + let needle = needle.trim().to_ascii_lowercase(); + if needle.is_empty() { + return false; + } + let found = self.items.iter().position(|item| { + item.model.to_ascii_lowercase().contains(&needle) + || item.label.to_ascii_lowercase().contains(&needle) + }); + let Some(found) = found else { + return false; + }; + let kind = self.items[found].kind; + self.switch(kind); + self.selected = self + .current() + .iter() + .position(|&index| index == found) + .unwrap_or(0); + true + } + + /// Номер текущей страницы (с нуля). + pub fn page(&self) -> usize { + self.selected / PAGE_SIZE + } + + /// Всего страниц в текущей категории. + pub fn page_count(&self) -> usize { + self.current().len().div_ceil(PAGE_SIZE).max(1) + } + + /// Обрабатывает клавишу открытого меню. + pub fn key(&mut self, code: Keycode) -> MenuAction { + match code { + Keycode::Left => self.move_selection(-1, 0), + Keycode::Right => self.move_selection(1, 0), + Keycode::Up => self.move_selection(0, -1), + Keycode::Down => self.move_selection(0, 1), + Keycode::PageUp => self.scroll(-1), + Keycode::PageDown => self.scroll(1), + Keycode::Tab => self.cycle(1), + Keycode::_1 => self.switch(Kind::Entity), + Keycode::_2 => self.switch(Kind::Weapon), + Keycode::_3 => self.switch(Kind::Prop), + Keycode::Return | Keycode::KpEnter => { + if let Some(item) = self.selected_item().cloned() { + return MenuAction::Spawn(item); + } + } + Keycode::Escape | Keycode::Q => return MenuAction::Close, + _ => {} + } + MenuAction::None + } + + /// Рисует меню поверх мира. Вызывать после HUD: текст идёт по порядку. + pub fn draw(&self, renderer: &mut WorldRenderer, width: f32, height: f32) { + if !self.open { + return; + } + let layout = Layout::new(width, height); + + // Модальное затемнение: без него текст теряется на светлом небе и + // белых стенах gm_construct. Два слоя — весь экран и плотнее под + // самим меню; панелей у клиента пока нет, заливка идёт глифом 127. + renderer.push_rect(0.0, 0.0, width, height, [0.01, 0.02, 0.04, 0.45]); + renderer.push_rect( + layout.left - 8.0, + 4.0, + width - 2.0 * (layout.left - 8.0), + height - 8.0, + [0.015, 0.02, 0.035, 0.55], + ); + + text( + renderer, + (width * 0.5 - 80.0).max(layout.left), + 14.0, + 2.0, + [1.0, 1.0, 1.0, 1.0], + "SPAWN MENU", + ); + + // Вкладки с количеством предметов: видно, где вообще есть что спавнить. + let mut x = layout.left; + for kind in Kind::ALL { + let active = kind == self.kind; + let label = format!( + "[{}] {} ({})", + kind.index() + 1, + kind.title(), + self.count(kind) + ); + let color = if active { + [1.0, 0.9, 0.35, 1.0] + } else { + [0.65, 0.65, 0.7, 1.0] + }; + text(renderer, x, 52.0, 1.5, color, label.clone()); + x += label.len() as f32 * 12.0 + 18.0; + } + + let rule = "=".repeat(((width - layout.left * 2.0) / 8.0).max(0.0) as usize); + text( + renderer, + layout.left, + 80.0, + 1.0, + [0.5, 0.55, 0.6, 0.8], + rule, + ); + + let page_start = self.page() * PAGE_SIZE; + let current = self.current(); + if current.is_empty() { + text( + renderer, + layout.left, + layout.top + 16.0, + 1.6, + [0.9, 0.9, 0.9, 1.0], + "No items in this category", + ); + } + + for cell in 0..PAGE_SIZE { + let index = page_start + cell; + let Some(&item_index) = current.get(index) else { + break; + }; + let Some(item) = self.items.get(item_index) else { + continue; + }; + let selected = index == self.selected; + let hovered = self.hover == Some(index); + let (x0, y0) = layout.cell_origin(cell); + + let prefix = if selected { "> " } else { " " }; + let label_width = (layout.cell_w / 11.2) as usize; + let label = format!("{prefix}{}", item.label); + let label_color = if selected || hovered { + [1.0, 0.9, 0.35, 1.0] + } else { + [0.92, 0.95, 1.0, 1.0] + }; + text( + renderer, + x0, + y0 + 10.0, + 1.4, + label_color, + truncate(&label, label_width.max(8)), + ); + if selected { + // Подсветка строки: без панелей это единственный «фон», + // который можно нарисовать текстурой шрифта. + let underline = "-".repeat((layout.cell_w / 9.0) as usize); + text( + renderer, + x0, + y0 + 28.0, + 1.0, + [1.0, 0.85, 0.3, 0.7], + underline, + ); + } + // Категория видна по вкладке, полный путь — в строке выбора: + // иначе на длинных spawnlist-ах текст не влезает в лимит вершин. + } + + text( + renderer, + layout.left, + height - 46.0, + 1.2, + [0.85, 0.9, 0.95, 1.0], + "Arrows select | Enter spawn | wheel/PageUp/PageDown page | Tab category | Esc close", + ); + let selected = self + .selected_item() + .map(|item| format!("{} ({})", item.label, item.model)) + .unwrap_or_else(|| "-".to_string()); + text( + renderer, + layout.left, + height - 26.0, + 1.2, + [0.7, 0.75, 0.8, 1.0], + truncate( + &format!( + "page {}/{} items {} selected: {}", + self.page() + 1, + self.page_count(), + current.len(), + selected + ), + (width / 9.6) as usize, + ), + ); + } +} + +/// Геометрия сетки: одна и та же для отрисовки и попаданий мышью. +struct Layout { + left: f32, + top: f32, + cell_w: f32, + cell_h: f32, +} + +impl Layout { + fn new(width: f32, height: f32) -> Self { + let left = 32.0; + let top = 100.0; + let bottom = (height - 72.0).max(top + ROWS as f32 * 24.0); + Self { + left, + top, + cell_w: ((width - left * 2.0) / COLS as f32).max(64.0), + cell_h: ((bottom - top) / ROWS as f32).max(24.0), + } + } + + fn cell_origin(&self, cell: usize) -> (f32, f32) { + let col = cell % COLS; + let row = cell / COLS; + ( + self.left + col as f32 * self.cell_w, + self.top + row as f32 * self.cell_h, + ) + } + + fn cell_at(&self, x: f32, y: f32) -> Option { + if x < self.left || y < self.top { + return None; + } + let col = ((x - self.left) / self.cell_w).floor() as usize; + let row = ((y - self.top) / self.cell_h).floor() as usize; + if col >= COLS || row >= ROWS { + return None; + } + Some(row * COLS + col) + } +} + +/// Текст с тенью: меню рисуется поверх мира, без тени строки теряются. +fn text( + renderer: &mut WorldRenderer, + x: f32, + y: f32, + scale: f32, + color: [f32; 4], + value: impl Into, +) { + let value = value.into(); + renderer.push_text(x + 1.0, y + 1.0, scale, [0.0, 0.0, 0.0, 0.7], value.clone()); + renderer.push_text(x, y, scale, color, value); +} + +/// Обрезает строку по числу символов (шрифт моноширинный). +fn truncate(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + let mut out: String = value.chars().take(max_chars.saturating_sub(2)).collect(); + out.push_str(".."); + out +} + +/// Нормализует путь модели: `\` → `/`, без пустых компонентов и `..`. +fn clean_model_path(raw: &str) -> Option { + let path = raw.trim().replace('\\', "/"); + let mut parts: Vec<&str> = Vec::new(); + for part in path.split('/') { + match part { + "" | "." => continue, + ".." => return None, + part => parts.push(part), + } + } + let path = parts.join("/"); + (!path.is_empty()).then_some(path) +} + +/// Подпись пропа: каталог и имя файла без `models/` и `.mdl`. +fn prop_label(model: &str) -> String { + let path = model.strip_prefix("models/").unwrap_or(model); + let path = path.strip_suffix(".mdl").unwrap_or(path); + // Длинные пути вроде `props_phx/construct/metal_plate1x2` оставляем + // осмысленными: последние два сегмента читаются лучше всего. + let parts: Vec<&str> = path.split('/').collect(); + let start = parts.len().saturating_sub(2); + parts[start..].join("/") +} + +/// Модели из `settings/spawnlist*` (KeyValues). Битые файлы пропускаются: +/// меню не должно падать из-за кривого пользовательского списка. +fn collect_spawnlist_models(vfs: &VirtualFs) -> Vec { + let mut out: Vec = Vec::new(); + // Пользовательский список важнее встроенного: в GMod `settings/spawnlist` + // перекрывает `spawnlist_default`. + for prefix in ["settings/spawnlist/", "settings/spawnlist_default/"] { + for path in vfs.list(prefix) { + if !path.to_ascii_lowercase().ends_with(".txt") { + continue; + } + let data = match vfs.read(&path) { + Ok(data) => data, + Err(err) => { + tracing::warn!(path = %path, "spawnlist не прочитан: {err}"); + continue; + } + }; + // Лимит из правила «данные из файлов — bounded»: списков много, + // но каждый должен быть вменяемого размера. + if data.len() > 8 * 1024 * 1024 { + tracing::warn!(path = %path, bytes = data.len(), "spawnlist слишком большой"); + continue; + } + let document = match formats::kv::KvDocument::from_bytes_lossy(&data) { + Ok(document) => document, + Err(err) => { + tracing::warn!(path = %path, "spawnlist не разобран: {err}"); + continue; + } + }; + collect_kv_models(&document, &mut out, 0); + if out.len() >= MAX_SPAWNLIST_MODELS { + tracing::warn!( + limit = MAX_SPAWNLIST_MODELS, + "spawnlist: достигнут лимит моделей" + ); + return out; + } + } + } + out +} + +/// Рекурсивно собирает пары `"model" "models/..."` из блока KeyValues. +fn collect_kv_models(document: &formats::kv::KvDocument, out: &mut Vec, depth: usize) { + // spawnlist вложен на 2–3 уровня (contents → запись → поля), глубже — + // уже не наш формат; ограничение защищает от рекурсии по данным. + if depth > 8 || out.len() >= MAX_SPAWNLIST_MODELS { + return; + } + for pair in &document.pairs { + match &pair.value { + formats::kv::KvValue::String(value) if pair.key.eq_ignore_ascii_case("model") => { + out.push(value.clone()); + } + formats::kv::KvValue::Block(child) => collect_kv_models(child, out, depth + 1), + _ => {} + } + } +} + +/// Точка спавна: перед игроком, на полу. +/// +/// Горизонтальный луч с габаритом пропа не даёт залезть в стену, затем луч +/// вниз ищет пол. Если пола нет (обрыв, край карты) — спавним на уровне глаз, +/// как и просит постановка: предмет не должен исчезнуть под миром. +pub fn spawn_origin(collision: &engine::Collision, player: &engine::Player, yaw: f32) -> Vec3 { + let (sin_yaw, cos_yaw) = yaw.to_radians().sin_cos(); + let forward = Vec3::new(cos_yaw, sin_yaw, 0.0); + let half = Vec3::new(8.0, 8.0, 8.0); + let start = player.position + Vec3::new(0.0, 0.0, 8.0); + let mut point = collision + .move_aabb(Aabb::new(start - half, start + half), forward * 72.0) + .aabb + .center(); + + // Не спавнить внутрь игрока: если стена вплотную, луч почти не прошёл — + // отодвигаем точку хотя бы на радиус тела. + let offset = Vec3::new( + point.x - player.position.x, + point.y - player.position.y, + 0.0, + ); + if offset.length() < 32.0 { + point += forward * (32.0 - offset.length()); + } + + let eye = player.eye_position(); + let probe_half = Vec3::new(6.0, 6.0, 6.0); + let probe = Vec3::new(point.x, point.y, eye.z); + let fall = collision.move_aabb( + Aabb::new(probe - probe_half, probe + probe_half), + Vec3::new(0.0, 0.0, -1024.0), + ); + let z = if fall.on_ground { + // Небольшой зазор: тело пропа опустится на пол само, зато не застрянет + // в браше, если габарит модели чуть больше пробы. + fall.aabb.max.z + 2.0 + } else { + eye.z + }; + Vec3::new(point.x, point.y, z) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn item(label: &str, kind: Kind) -> SpawnItem { + SpawnItem { + label: label.to_string(), + model: format!("models/test/{label}.mdl"), + kind, + } + } + + fn menu(count: usize) -> SpawnMenu { + let items = (0..count) + .map(|index| item(&format!("p{index}"), Kind::Prop)) + .collect(); + SpawnMenu::from_items(items) + } + + #[test] + fn model_paths_are_cleaned_without_escaping() { + assert_eq!( + clean_model_path(r"models\\props//junk\watermelon01.mdl").as_deref(), + Some("models/props/junk/watermelon01.mdl") + ); + assert_eq!(clean_model_path("../secret.mdl"), None); + assert_eq!(clean_model_path(" "), None); + assert_eq!( + clean_model_path("/models/a.mdl").as_deref(), + Some("models/a.mdl") + ); + } + + #[test] + fn prop_label_keeps_last_two_segments() { + assert_eq!( + prop_label("models/props_junk/watermelon01.mdl"), + "props_junk/watermelon01" + ); + assert_eq!(prop_label("models/hunter.mdl"), "hunter"); + } + + #[test] + fn long_labels_are_truncated() { + assert_eq!(truncate("short", 10), "short"); + assert_eq!(truncate("abcdefghijkl", 8), "abcdef.."); + assert_eq!(truncate("abcdefgh", 8), "abcdefgh"); + } + + #[test] + fn grid_navigation_moves_between_rows_and_clamps() { + let mut menu = menu(10); + assert_eq!(menu.selected, 0); + menu.move_selection(1, 0); + assert_eq!(menu.selected, 1); + menu.move_selection(0, 1); + assert_eq!(menu.selected, 1 + COLS); + // Вниз дальше конца — остаёмся на последнем предмете. + menu.move_selection(0, 1); + assert_eq!(menu.selected, 9); + menu.move_selection(1, 0); + assert_eq!(menu.selected, 9); + } + + #[test] + fn scrolling_changes_page_and_keeps_selection_valid() { + let mut menu = menu(50); + assert_eq!(menu.page_count(), 50usize.div_ceil(PAGE_SIZE)); + menu.scroll(1); + assert_eq!(menu.page(), 1); + assert_eq!(menu.selected, PAGE_SIZE); + menu.scroll(5); + let last = menu.page_count() - 1; + assert_eq!(menu.page(), last); + assert_eq!(menu.selected, last * PAGE_SIZE); + menu.scroll(-5); + assert_eq!(menu.selected, 0); + } + + #[test] + fn clicking_a_cell_selects_and_returns_item() { + let mut menu = menu(30); + let (w, h) = (1280.0, 720.0); + let layout = Layout::new(w, h); + let (x, y) = layout.cell_origin(5); + let clicked = menu + .click(x + 4.0, y + 4.0, w, h) + .expect("предмет под курсором"); + assert_eq!(clicked.label, "p5"); + assert_eq!(menu.selected, 5); + // Клик вне сетки ничего не делает. + assert!(menu.click(2.0, 2.0, w, h).is_none()); + } + + #[test] + fn second_page_click_maps_to_real_index() { + let mut menu = menu(40); + menu.scroll(1); + let (w, h) = (1280.0, 720.0); + let layout = Layout::new(w, h); + let (x, y) = layout.cell_origin(0); + let clicked = menu + .click(x + 4.0, y + 4.0, w, h) + .expect("предмет на второй странице"); + assert_eq!(clicked.label, format!("p{PAGE_SIZE}")); + } + + #[test] + fn categories_switch_and_keep_independent_items() { + let items = vec![ + item("e0", Kind::Entity), + item("p0", Kind::Prop), + item("w0", Kind::Weapon), + item("p1", Kind::Prop), + ]; + let mut menu = SpawnMenu::from_items(items); + assert_eq!(menu.count(Kind::Entity), 1); + assert_eq!(menu.count(Kind::Weapon), 1); + assert_eq!(menu.count(Kind::Prop), 2); + menu.switch(Kind::Entity); + assert_eq!(menu.selected_item().map(|i| i.label.as_str()), Some("e0")); + menu.cycle(1); + assert_eq!(menu.kind(), Kind::Weapon); + menu.cycle(-1); + assert_eq!(menu.kind(), Kind::Entity); + } + + #[test] + fn select_matching_switches_category() { + let items = vec![ + item("e0", Kind::Entity), + item("p0", Kind::Prop), + item("watermelon", Kind::Prop), + ]; + let mut menu = SpawnMenu::from_items(items); + assert!(menu.select_matching("watermelon")); + assert_eq!(menu.kind(), Kind::Prop); + assert_eq!( + menu.selected_item().map(|i| i.label.as_str()), + Some("watermelon") + ); + assert!(!menu.select_matching("нет такого")); + } + + #[test] + fn escape_and_q_close_the_menu() { + let mut menu = menu(3); + assert!(matches!(menu.key(Keycode::Escape), MenuAction::Close)); + assert!(matches!(menu.key(Keycode::Q), MenuAction::Close)); + assert!(matches!(menu.key(Keycode::Return), MenuAction::Spawn(_))); + } +} diff --git a/docs/DIARY.md b/docs/DIARY.md index cee405a..ca2fd2e 100644 --- a/docs/DIARY.md +++ b/docs/DIARY.md @@ -142,7 +142,37 @@ HL2: туда залит только `garrysmod/`, без `sourceengine/`. Пр альфа из текстуры). Проверено кадром: граффити с прозрачностью на кирпиче. Осталось из визуального: `env_skypaint` (цвет неба), спрайты/glow (на -gm_construct их нет), Q-меню спавна и SWEP из Lua. +gm_construct их нет), SWEP из Lua. + +## 0.7. Q-меню спавна (2026-10-24, ветка `feat/qmenu`) + +Сделано ядро A24 — меню спавна по клавише Q, без VGUI: + +- **Список** собирается при старте: `list.Get("SpawnableEntities")` (только + `Spawnable = true`), `list.Get("Weapon")` (`WorldModel`, иначе `ViewModel`) и + модели из `settings/spawnlist_default/*.txt` (KeyValues). Все пути проверяются + `vfs.exists`; на текущих ассетах: 1 энтити, 3 оружия, 5656 пропов (1817 путей + из spawnlist отсутствуют и отброшены). Модель ENT-скрипта, заданная только в + коде (`self:SetModel`), берётся первым литералом `models/*.mdl` из исходника. +- **UI** — текст поверх затемнения: вкладки 1/2/3, сетка 4×8, стрелки/Enter, + мышь (наведение и клик), колесо и PageUp/PageDown, Esc/Q закрывают. Пока меню + открыто, игрок не ходит, камера не вращается, ЛКМ не берёт физган; после + закрытия управление и захват мыши возвращаются. +- **Спавн** — в точку перед игроком: горизонтальный луч с габаритом пропа, + затем луч вниз до пола; если пола нет — на уровне глаз. Внутрь игрока не + ставим. Путь спавна общий с G (`spawn_prop` + досборка рендерера). +- **Проверки:** клик/Enter спавнят (кадр `interactive2.png`), G по-прежнему + спавнит (`gspawn.png`), при открытом меню позиция не меняется (`frozen.png`), + после закрытия W снова двигает (`resume.png`); скриншоты меню — `final_menu.png`, + `menu_entity.png`, `menu_weapon.png`, спавн арбуза — `final_spawn.png`. +- **Отладка:** `SOURCE_RS_QMENU=1` — открыть меню на старте; + `SOURCE_RS_QMENU_ITEM=<подстрока>` — выбрать предмет; `SOURCE_RS_QMENU_SPAWN=1` + — заспавнить выбранный сразу (проверка кадром). +- **Тесты:** `qmenu` (навигация, страницы, попадание мышью, категории), + `glua` (поля ENT-реестра, поиск литерала модели), `render` (сплошной глиф). + +Осталось: иконки-превью вместо текста, подкатегории spawnlist (сейчас один +общий список пропов), серверный спавн настоящих ENT вместо пропа-представителя. ## 1. Что просит пользователь (приоритеты)