Слияние feat/qmenu: Q-меню спавна и SWEP в клиенте
Some checks are pending
CI / checks (push) Waiting to run
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:
commit
8aff0caf12
7 changed files with 1383 additions and 111 deletions
|
|
@ -9,6 +9,7 @@ mod menu;
|
|||
#[cfg(not(target_os = "android"))]
|
||||
mod net_cmd;
|
||||
mod pack;
|
||||
mod qmenu;
|
||||
mod touch;
|
||||
mod weapon;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
887
crates/client/src/qmenu.rs
Normal 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(_)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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. Что просит пользователь (приоритеты)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue