Слияние feat/qmenu: Q-меню спавна и SWEP в клиенте
Some checks are pending
CI / checks (push) Waiting to run

- map.rs: объединены ветки ввода. Пока меню открыто, Esc/Q/цифры/ЛКМ
  принадлежат меню (клик выбирает предмет, игрок стоит), иначе работают
  SWEP-атака и физган; отпускание ЛКМ снимает primary_up у SWEP и не
  трогает физган при открытом меню. Спавн из меню и по G идёт общим
  SpawnRequest с досборкой рендерера. HUD показывает активное оружие и
  подсказку с Q; вьюмодель SWEP сохранена.
- lua/lib.rs: сохранены и API SWEP (weapon_number, weapon_bool,
  set_active_weapon, cur_time, weapon_field, weapon_table), и ENT-API
  (entity_string, entity_bool), и лексер/препроцессор continue
  (Token, preprocess_continue, is_statement_continue, read_script),
  и find_model_literal для спавн-меню.
- render: push_rect и сплошной глиф для заливки оверлея.

Проверено: cargo fmt, clippy --workspace --all-targets -D warnings,
cargo test --workspace; glua с ассетами — оружия 3, энтити 6, ошибок 0;
engine world_real — 21 тест; кадры Q-меню (арбуз заспавнен перед камерой)
и SWEP (weapon_fists в HUD). При открытом меню ЛКМ не стреляет, после
закрытия ЛКМ вызывает attack: weapon_fists.
This commit is contained in:
Niko Marmeladkov 2026-10-24 13:45:44 +03:00
commit 8aff0caf12
7 changed files with 1383 additions and 111 deletions

View file

@ -9,6 +9,7 @@ mod menu;
#[cfg(not(target_os = "android"))]
mod net_cmd;
mod pack;
mod qmenu;
mod touch;
mod weapon;

View file

@ -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;
@ -242,6 +243,13 @@ fn desired_view_model(
}
}
/// Запрос на спавн пропа из меню или клавиши G.
struct SpawnRequest {
model: String,
origin: math::Vec3,
angles: math::Vec3,
}
/// Аргументы команды `map`.
#[derive(Debug, clap::Args)]
pub struct MapArgs {
@ -699,6 +707,28 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
.ok()
.and_then(|value| value.trim().parse().ok())
.unwrap_or(26.0);
// 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<SpawnRequest> = 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);
}
// Время для процедурной пробы скиннинга вьюмодели (A26).
let mut wave_time = 0.0f32;
// Физган: тело, которое держим, и расстояние удержания (A21/A23).
@ -731,69 +761,122 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
Event::Quit { .. } => break 'running,
Event::KeyDown {
keycode: Some(Keycode::Escape),
..
} => break 'running,
Event::MouseButtonDown {
mouse_btn: MouseButton::Left,
repeat: false,
..
} => {
// Активный SWEP забирает ЛКМ себе: `PrimaryAttack` —
// единственный путь, которым скрипт делает выстрел.
let mut weapon_attack = false;
if let (Some(lua), Some(state)) = (lua.as_ref(), swep.as_mut()) {
weapon_attack = state.primary_down(lua);
// Esc в открытом меню закрывает его, а не клиент.
if spawn_menu.is_open() {
close_menu(&mut spawn_menu, &sdl, &window, relative_mouse);
} else {
break 'running;
}
if weapon_attack {
if view_model.fire.is_some() {
view_model.fire_time = Some(0.0);
}
if let Some(name) = swep
.as_ref()
.and_then(|state| state.active_info())
.map(|weapon| weapon.display_name().to_string())
{
log.push_back(format!("attack: {name}"));
}
}
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();
}
} 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));
}
}
}
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));
}
if let Some((along, id)) = best {
grabbed = Some((id, along, math::Vec3::ZERO));
log.push_back(format!("физган: взял ({} юнитов)", along as i32));
} else {
// Активный SWEP забирает ЛКМ себе: `PrimaryAttack` —
// единственный путь, которым скрипт делает выстрел.
let mut weapon_attack = false;
if let (Some(lua), Some(state)) = (lua.as_ref(), swep.as_mut()) {
weapon_attack = state.primary_down(lua);
}
if weapon_attack {
if view_model.fire.is_some() {
view_model.fire_time = Some(0.0);
}
if let Some(name) = swep
.as_ref()
.and_then(|state| state.active_info())
.map(|weapon| weapon.display_name().to_string())
{
log.push_back(format!("attack: {name}"));
}
while log.len() > 8 {
log.pop_front();
}
} else if view_model.fire.is_some() {
// Под прицелом нет тела — ЛКМ стреляет: одноразовая
// анимация вьюмодели, без оружия и урона (A23).
view_model.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_model.fire.is_some() {
// Под прицелом нет тела — ЛКМ стреляет: одноразовая
// анимация вьюмодели, без оружия и урона (A23).
view_model.fire_time = Some(0.0);
log.push_back("выстрел".to_string());
while log.len() > 8 {
log.pop_front();
}
}
}
}
@ -805,7 +888,8 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
if let Some(state) = swep.as_mut() {
state.primary_up();
}
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();
@ -858,51 +942,18 @@ pub fn map(assets: Option<PathBuf>, 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),
@ -940,12 +991,20 @@ pub fn map(assets: Option<PathBuf>, 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);
@ -963,7 +1022,9 @@ pub fn map(assets: Option<PathBuf>, 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);
}
}
@ -990,6 +1051,53 @@ pub fn map(assets: Option<PathBuf>, 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 {
@ -1075,7 +1183,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() {
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;
@ -1141,7 +1249,13 @@ pub fn map(assets: Option<PathBuf>, 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 {
// Как в GMod: Shift ускоряет, Ctrl замедляет (а не опускает).
let speed = if pressed.contains(&Keycode::LShift) {
args.speed * 3.0
@ -1456,17 +1570,20 @@ 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);
}
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 | 0-9 weapon | ` 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 | 0-9 weapon | ` log"
.to_string()
},
);
}
if touch_enabled {
// Подписи зон: сами зоны заданы в `touch.rs`, здесь только
// ориентиры, чтобы на телефоне было понятно, куда нажимать.
@ -1500,6 +1617,9 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
}
}
// Q-меню поверх HUD: выбор предмета и подсказки по управлению.
spawn_menu.draw(&mut renderer, width as f32, height as f32);
if frames_drawn % 60 == 0 {
tracing::debug!(
z = player.position.z,
@ -1667,6 +1787,48 @@ pub fn map(assets: Option<PathBuf>, 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()))?;

887
crates/client/src/qmenu.rs Normal file
View file

@ -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<SpawnItem>,
/// Индексы предметов каждой категории (порядок отображения).
by_kind: [Vec<usize>; 3],
kind: Kind,
/// Выбранный предмет — индекс внутри текущей категории.
selected: usize,
open: bool,
/// Предмет под курсором на текущей странице (для подсветки).
hover: Option<usize>,
}
impl SpawnMenu {
/// Собирает список предметов из Lua-реестров и spawnlist в VFS.
pub fn collect(lua: Option<&glua::LuaRuntime>, vfs: &VirtualFs) -> Self {
let mut items: Vec<SpawnItem> = 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<String> = 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<SpawnItem>) -> Self {
let mut by_kind: [Vec<usize>; 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<SpawnItem> {
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<usize> {
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<usize> {
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<String>,
) {
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<String> {
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<String> {
let mut out: Vec<String> = 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<String>, 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(_)));
}
}

View file

@ -204,6 +204,40 @@ impl LuaRuntime {
stdlib::cur_time()
}
/// Строковое поле ENT-скрипта (`Model`, `WorldModel`, `PrintName`, ...).
///
/// Нужен спавн-меню: список `SpawnableEntities` хранит только имена, а
/// модель сущности лежит в её таблице. Как и у оружия, `Model("...")`
/// приходит таблицей с полем `path`.
pub fn entity_string(&self, class: &str, field: &str) -> Option<String> {
let globals = self.lua.globals();
let store: Table = globals.get("__sr_ents").ok()?;
let Value::Table(ent) = store.get::<Value>(class).ok()? else {
return None;
};
match ent.get::<Value>(field) {
Ok(Value::String(value)) => Some(value.to_string_lossy().to_string()),
Ok(Value::Table(value)) => value.get::<Option<String>>("path").ok().flatten(),
_ => None,
}
}
/// Булево поле ENT-скрипта (в спавн-меню — `Spawnable`).
///
/// `nil` и не-булевы значения дают `None`: фильтр меню отличает
/// «явно false» от «поле не задано».
pub fn entity_bool(&self, class: &str, field: &str) -> Option<bool> {
let globals = self.lua.globals();
let store: Table = globals.get("__sr_ents").ok()?;
let Value::Table(ent) = store.get::<Value>(class).ok()? else {
return None;
};
match ent.get::<Value>(field) {
Ok(Value::Boolean(value)) => Some(value),
_ => None,
}
}
/// Загружает скрипты GMod: `lua/weapons/*.lua` и `lua/entities/*.lua`.
///
/// Каждый скрипт выполняется со свежей таблицей `SWEP`/`ENT` (у оружия
@ -266,6 +300,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()) {
@ -1389,6 +1433,47 @@ fn read_script(path: &std::path::Path) -> Option<String> {
std::fs::read_to_string(path).ok()
}
/// Первый литерал `models/*.mdl` в исходнике ENT-скрипта.
///
/// Спавн-меню нужно чем-то показать сущность, у которой модель задаётся
/// вызовом `SetModel` в `Initialize`, а не полем `ENT.Model`. Поиск
/// ограничен: путь короче 256 байт, только до кавычки/пробела/закрывающей
/// скобки — данные из файла не должны влиять на границы среза.
fn find_model_literal(source: &str) -> Option<String> {
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'[') {
@ -1702,4 +1787,51 @@ result = out
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())
);
}
}

View file

@ -30,7 +30,7 @@ use font8x8::UnicodeFonts;
/// Строит атлас глифов ASCII 32..127 (R8, альфа).
pub fn build_atlas() -> Vec<u8> {
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<u8> {
}
}
}
// 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 {

View file

@ -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::<TextVertex>()) 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];

View file

@ -181,7 +181,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. Что просит пользователь (приоритеты)