From 1fe36b6869e41c29d62c435ca37a6557651d159c Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Sat, 24 Oct 2026 13:29:41 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BB=D0=B8=D0=B5=D0=BD=D1=82:=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B1=D0=BE=D1=80=20SWEP,=20=D1=81=D0=BB=D0=BE=D1=82?= =?UTF-8?q?=D1=8B,=20=D0=B0=D1=82=D0=B0=D0=BA=D0=B0=20=D0=B8=20HUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - weapon.rs: реестр list.Get("Weapon") с фильтром Spawnable/ViewModel, слоты Slot/SlotPos, SOURCE_RS_SWEP, Holster/Deploy при смене; - map.rs: клавиши 0..9 выбирают слот, вьюмодель пересоздаётся под SWEP.ViewModel (SOURCE_RS_VIEW_MODEL остаётся override), ЛКМ вызывает PrimaryAttack, автоматический огонь по Primary.Automatic и GetNextPrimaryFire, Think каждый кадр; - HUD: PrintName активного оружия и патроны Clip1/ClipSize; - SOURCE_RS_TEST_ATTACK и SOURCE_RS_TEST_SWITCH для проверки без мыши; - ошибка Lua в методах оружия логируется, кадр не падает. --- crates/client/src/lib.rs | 1 + crates/client/src/map.rs | 701 +++++++++++++++++++++++++----------- crates/client/src/weapon.rs | 351 ++++++++++++++++++ 3 files changed, 839 insertions(+), 214 deletions(-) create mode 100644 crates/client/src/weapon.rs diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index e1f768e..49ac8f7 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -10,6 +10,7 @@ mod menu; mod net_cmd; mod pack; mod touch; +mod weapon; use clap::{Parser, Subcommand}; use std::path::PathBuf; diff --git a/crates/client/src/map.rs b/crates/client/src/map.rs index 7c6e821..f389e35 100644 --- a/crates/client/src/map.rs +++ b/crates/client/src/map.rs @@ -45,6 +45,203 @@ fn upload_debug_collision( } use tracing::{debug, info, warn}; +/// Вьюмодель по умолчанию, когда SWEP не выбран (прежнее поведение). +const DEFAULT_VIEW_MODEL: &str = "models/weapons/w_pistol.mdl"; + +/// Создаёт экземпляр вьюмодели; возвращает индекс и центр габарита. +/// +/// `allow_fallback` включает откат на пистолет/монтировку — только для +/// отладочного override и пустого слота. Модель SWEP не подменяется: иначе +/// кулаки (`c_arms.mdl` — скелет без геометрии) выглядели бы пистолетом. +fn spawn_view_model( + world: &std::rc::Rc>, + vfs: &VirtualFs, + name: &str, + eye: math::Vec3, + allow_fallback: bool, +) -> Option<(usize, math::Vec3)> { + let mut candidates = vec![name.to_string()]; + if allow_fallback { + candidates.push(DEFAULT_VIEW_MODEL.to_string()); + candidates.push("models/weapons/v_crowbar.mdl".to_string()); + } + for candidate in candidates { + let mut budget = usize::MAX; + let mut w = world.borrow_mut(); + let Some(index) = + w.spawn_model_instance(vfs, &candidate, eye, math::Vec3::ZERO, &mut budget) + else { + continue; + }; + let diag = w.model_instances.get(index).map(|i| { + let mesh = w.prop_meshes.get(i.mesh); + ( + i.mesh, + i.first_vertex, + i.vertex_count, + mesh.map(|m| m.positions.first().copied()), + mesh.map(|m| m.bounds_half_extents), + mesh.map(|m| m.positions.len()), + w.batches + .last() + .map(|b| (b.material, b.first_index, b.index_count)), + ) + }); + let centre = w + .model_instances + .get(index) + .and_then(|i| w.prop_meshes.get(i.mesh)) + .map(|m| m.bounds_center) + .unwrap_or(math::Vec3::ZERO); + info!(index, model = %candidate, ?diag, ?centre, "вьюмодель в мире"); + if let (Some(instance), Some(batch)) = (w.model_instances.get(index), w.batches.last()) { + let from = batch.first_index as usize; + let count = batch.index_count as usize; + let sample: Vec = w + .indices + .get(from..(from + count).min(from + 6)) + .map(|s| s.to_vec()) + .unwrap_or_default(); + let lo = instance.first_vertex; + let hi = lo + instance.vertex_count; + let inside = w + .indices + .get(from..from + count) + .map(|s| s.iter().all(|&i| i >= lo && i < hi)) + .unwrap_or(false); + info!(batch = ?batch, ?sample, lo, hi, inside, "индексы вьюмодели"); + } + return Some((index, centre)); + } + warn!(model = %name, "вьюмодель не загрузилась"); + None +} + +/// Вьюмодель клиента: модель, анимации покоя/выстрела и их состояние. +struct ViewModelState { + instance: Option, + center: math::Vec3, + idle: Option, + fire: Option, + fire_duration: f32, + /// Отсчёт одноразовой анимации выстрела. + fire_time: Option, +} + +impl ViewModelState { + fn new( + world: &std::rc::Rc>, + vfs: &VirtualFs, + model: Option<&str>, + eye: math::Vec3, + allow_fallback: bool, + ) -> Self { + let mut state = Self { + instance: None, + center: math::Vec3::ZERO, + idle: None, + fire: None, + fire_duration: 0.4, + fire_time: None, + }; + state.set_model(world, vfs, model, eye, allow_fallback); + state + } + + /// Меняет модель. Старый экземпляр уводится далеко под карту: удалять + /// вершины из общих буферов дорого, а пересоздание при смене оружия + /// происходит редко. + fn set_model( + &mut self, + world: &std::rc::Rc>, + vfs: &VirtualFs, + model: Option<&str>, + eye: math::Vec3, + allow_fallback: bool, + ) { + if let Some(old) = self.instance.take() { + world.borrow_mut().move_model_instance( + old, + math::Vec3::new(0.0, 0.0, -100_000.0), + math::Vec3::ZERO, + ); + } + self.center = math::Vec3::ZERO; + self.idle = None; + self.fire = None; + self.fire_duration = 0.4; + self.fire_time = None; + let Some(model) = model else { + return; + }; + let Some((index, center)) = spawn_view_model(world, vfs, model, eye, allow_fallback) else { + return; + }; + self.instance = Some(index); + self.center = center; + self.refresh_animations(world); + } + + /// Имена анимаций: у `v_pistol` — `idle01`/`fire`, у других моделей + /// наборы отличаются, поэтому берём первую существующую. + /// + /// `idle` у мировой модели оружия (`w_pistol`) — поза брошенного на землю + /// пистолета, в руках она не годится: без `idle01` остаёмся в позе покоя. + fn refresh_animations( + &mut self, + world: &std::rc::Rc>, + ) { + let Some(index) = self.instance else { + return; + }; + let w = world.borrow(); + self.idle = ["idle01", "idle01empty"] + .into_iter() + .find(|name| w.model_has_animation(index, name)) + .map(str::to_string); + self.fire = ["fire", "fire1", "attack_player"] + .into_iter() + .find(|name| w.model_has_animation(index, name)) + .map(str::to_string); + self.fire_duration = self + .fire + .as_deref() + .and_then(|name| w.model_animation_duration(index, name)) + .unwrap_or(0.4); + } +} + +/// Клавиша слота GMod: `1..9` — слоты 1..9, `0` — слот 0 (кулаки). +fn weapon_slot_for_key(code: Keycode) -> Option { + match code { + Keycode::_0 | Keycode::Kp0 => Some(0), + Keycode::_1 | Keycode::Kp1 => Some(1), + Keycode::_2 | Keycode::Kp2 => Some(2), + Keycode::_3 | Keycode::Kp3 => Some(3), + Keycode::_4 | Keycode::Kp4 => Some(4), + Keycode::_5 | Keycode::Kp5 => Some(5), + Keycode::_6 | Keycode::Kp6 => Some(6), + Keycode::_7 | Keycode::Kp7 => Some(7), + Keycode::_8 | Keycode::Kp8 => Some(8), + Keycode::_9 | Keycode::Kp9 => Some(9), + _ => None, + } +} + +/// Модель для текущего состояния: override отладки важнее SWEP. +fn desired_view_model( + override_path: &Option>, + swep: Option<&crate::weapon::SwepState>, +) -> Option { + match override_path { + Some(choice) => choice.clone(), + None => swep + .and_then(crate::weapon::SwepState::active_view_model) + .map(str::to_string) + .or_else(|| Some(DEFAULT_VIEW_MODEL.to_string())), + } +} + /// Аргументы команды `map`. #[derive(Debug, clap::Args)] pub struct MapArgs { @@ -251,83 +448,106 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { index }); - // Вьюмодель (оружие в руках, A23): тот же рендер моделей, что у пропов и - // модели игрока. Пока статическая, без анимаций — срез, который даёт - // «оружие в руках», не блокируя остальной Sandbox. - // `SOURCE_RS_VIEW_MODEL` — путь к модели; пустое значение — физган, а если - // его в ассетах нет — монтировка. - let mut view_model_center = math::Vec3::ZERO; - // По умолчанию вьюмодель включена (пистолет); `SOURCE_RS_VIEW_MODEL=off` - // её гасит, другое значение — путь к модели. - let view_model_instance = match std::env::var("SOURCE_RS_VIEW_MODEL") { - Ok(value) if value.trim().eq_ignore_ascii_case("off") => None, - Ok(value) if value.trim().is_empty() => Some("models/weapons/w_pistol.mdl".to_string()), - Ok(value) => Some(value), - Err(_) => Some("models/weapons/w_pistol.mdl".to_string()), - } - .and_then(|name| { - let candidates = [ - name.clone(), - "models/weapons/w_pistol.mdl".to_string(), - "models/weapons/v_crowbar.mdl".to_string(), - ]; - for candidate in candidates { - let mut budget = usize::MAX; - let mut w = world.borrow_mut(); - if let Some(index) = w.spawn_model_instance( - &vfs, - &candidate, - player.eye_position(), - math::Vec3::ZERO, - &mut budget, - ) { - let diag = w.model_instances.get(index).map(|i| { - let mesh = w.prop_meshes.get(i.mesh); - ( - i.mesh, - i.first_vertex, - i.vertex_count, - mesh.map(|m| m.positions.first().copied()), - mesh.map(|m| m.bounds_half_extents), - mesh.map(|m| m.positions.len()), - w.batches - .last() - .map(|b| (b.material, b.first_index, b.index_count)), - ) - }); - let centre = w - .model_instances - .get(index) - .and_then(|i| w.prop_meshes.get(i.mesh)) - .map(|m| m.bounds_center) - .unwrap_or(math::Vec3::ZERO); - info!(index, model = %candidate, ?diag, ?centre, "вьюмодель в мире"); - view_model_center = centre; - if let (Some(instance), Some(batch)) = - (w.model_instances.get(index), w.batches.last()) - { - let from = batch.first_index as usize; - let count = batch.index_count as usize; - let sample: Vec = w - .indices - .get(from..(from + count).min(from + 6)) - .map(|s| s.to_vec()) - .unwrap_or_default(); - let lo = instance.first_vertex; - let hi = lo + instance.vertex_count; - let inside = w - .indices - .get(from..from + count) - .map(|s| s.iter().all(|&i| i >= lo && i < hi)) - .unwrap_or(false); - info!(batch = ?batch, ?sample, lo, hi, inside, "индексы вьюмодели"); - } - return Some(index); + // Лог для HUD создаём до Lua: ошибки загрузки скриптов попадают в него. + let mut log: std::collections::VecDeque = std::collections::VecDeque::new(); + let mut show_log = false; + log.push_back(format!("map: {}", args.map)); + + // Lua-рантайм: автозагрузка клиентских скриптов (lua/autorun/client). + // Инициализация идёт до вьюмодели: выбор оружия (`SOURCE_RS_SWEP`) + // определяет, какую модель показывать в руках. + let lua = match glua::LuaRuntime::new() { + Ok(runtime) => Some(runtime), + Err(err) => { + warn!("Lua не запущен: {err}"); + None + } + }; + if let Some(lua) = &lua { + // Хост отдаёт Lua доступ к живому миру: `Entity()` и `ents.*` + // начинают видеть настоящие сущности, а не заглушку. + if let Err(err) = lua.attach_host(std::rc::Rc::new(std::cell::RefCell::new( + crate::host::WorldHost::new(world.clone(), None), + ))) { + warn!("хост Lua не подключён: {err}"); + } + // `file.*` с песочницой на каталог garrysmod: скрипты GMod читают и + // пишут внутри него, а выход наружу должен быть отказом, а не + // предупреждением (правило #4 AGENTS.md про ассеты). + if let Some(root) = config.assets.as_deref().map(|p| p.join("garrysmod")) { + match lua.set_file_root(&root) { + Ok(()) => info!(root = %root.display(), "file.* подключён"), + Err(err) => warn!("file.* недоступен: {err}"), } } - warn!("вьюмодель не загрузилась"); - None - }); + let _ = lua.set_map(&args.map); + let mut loaded = 0usize; + for path in vfs.list("lua/autorun/client/") { + if !path.ends_with(".lua") { + continue; + } + match vfs.read(&path) { + Ok(data) => match lua.run(&path, &data) { + Ok(()) => loaded += 1, + Err(err) => { + log.push_back(format!("lua err {}: {}", short_path(&path), err)); + warn!(path = %path, "lua-скрипт не выполнен: {err}"); + } + }, + Err(err) => warn!(path = %path, "lua-файл не прочитан: {err}"), + } + } + if loaded > 0 { + info!(loaded, "Lua: autorun client загружен"); + log.push_back(format!("lua: {loaded} autorun client")); + } + // Скрипты GMod: оружие (`lua/weapons`) и сущности (`lua/entities`). + // Они пополняют `list.Get("Weapon")` и `scripted_ents` — из этих + // реестров строятся меню и выбор оружия. + if let Some(root) = config.assets.as_deref().map(|path| path.join("garrysmod")) { + let (weapons, entities, failed) = lua.load_scripts(&root); + info!(weapons, entities, failed, "Lua: скрипты GMod"); + if weapons > 0 { + log.push_back(format!("lua: оружия {weapons}, энтити {entities}")); + } + } + } + + // Реестр SWEP и активное оружие. Пустой активный слот — это прежнее + // поведение: пистолет-заглушка и физган на ЛКМ. + let mut swep = lua + .as_ref() + .map(|lua| crate::weapon::SwepState::new(lua, &vfs)); + if let Some(state) = &swep { + if state.weapons.is_empty() { + log.push_back("swep: нет доступного оружия".to_string()); + } else if let Some(weapon) = state.active_info() { + log.push_back(format!("swep: {}", weapon.display_name())); + } + } + + // Вьюмодель (оружие в руках, A23): `SOURCE_RS_VIEW_MODEL` — override для + // отладки (`off` гасит, пустое значение — пистолет), иначе модель + // активного SWEP, а при пустом слоте — прежний пистолет. + let view_model_override = match std::env::var("SOURCE_RS_VIEW_MODEL") { + Ok(value) if value.trim().eq_ignore_ascii_case("off") => Some(None), + Ok(value) if value.trim().is_empty() => Some(Some(DEFAULT_VIEW_MODEL.to_string())), + Ok(value) => Some(Some(value)), + Err(_) => None, + }; + let initial_view_model = desired_view_model(&view_model_override, swep.as_ref()); + // Откат на пистолет нужен только для отладочного override и пустого + // слота. У SWEP откат запрещён: `c_arms.mdl` — скелет без геометрии, и + // подменять кулаки пистолетом значит врать игроку. + let view_model_fallback = + view_model_override.is_some() || swep.as_ref().is_none_or(|state| state.active.is_none()); + let mut view_model = ViewModelState::new( + &world, + &vfs, + initial_view_model.as_deref(), + player.eye_position(), + view_model_fallback, + ); // `--spawn <модель>` — спавн пропов на старте (отладка Q-меню, A24). for model in &args.spawn { @@ -479,68 +699,6 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { .ok() .and_then(|value| value.trim().parse().ok()) .unwrap_or(26.0); - let mut log: std::collections::VecDeque = std::collections::VecDeque::new(); - let mut show_log = false; - log.push_back(format!("map: {}", args.map)); - - // Lua-рантайм: автозагрузка клиентских скриптов (lua/autorun/client). - let lua = match glua::LuaRuntime::new() { - Ok(runtime) => Some(runtime), - Err(err) => { - warn!("Lua не запущен: {err}"); - None - } - }; - if let Some(lua) = &lua { - // Хост отдаёт Lua доступ к живому миру: `Entity()` и `ents.*` - // начинают видеть настоящие сущности, а не заглушку. - if let Err(err) = lua.attach_host(std::rc::Rc::new(std::cell::RefCell::new( - crate::host::WorldHost::new(world.clone(), None), - ))) { - warn!("хост Lua не подключён: {err}"); - } - // `file.*` с песочницой на каталог garrysmod: скрипты GMod читают и - // пишут внутри него, а выход наружу должен быть отказом, а не - // предупреждением (правило #4 AGENTS.md про ассеты). - if let Some(root) = config.assets.as_deref().map(|p| p.join("garrysmod")) { - match lua.set_file_root(&root) { - Ok(()) => info!(root = %root.display(), "file.* подключён"), - Err(err) => warn!("file.* недоступен: {err}"), - } - } - let _ = lua.set_map(&args.map); - let mut loaded = 0usize; - for path in vfs.list("lua/autorun/client/") { - if !path.ends_with(".lua") { - continue; - } - match vfs.read(&path) { - Ok(data) => match lua.run(&path, &data) { - Ok(()) => loaded += 1, - Err(err) => { - log.push_back(format!("lua err {}: {}", short_path(&path), err)); - warn!(path = %path, "lua-скрипт не выполнен: {err}"); - } - }, - Err(err) => warn!(path = %path, "lua-файл не прочитан: {err}"), - } - } - if loaded > 0 { - info!(loaded, "Lua: autorun client загружен"); - log.push_back(format!("lua: {loaded} autorun client")); - } - // Скрипты GMod: оружие (`lua/weapons`) и сущности (`lua/entities`). - // Они пополняют `list.Get("Weapon")` и `scripted_ents` — из этих - // реестров строятся меню и выбор оружия. - if let Some(root) = config.assets.as_deref().map(|path| path.join("garrysmod")) { - let (weapons, entities, failed) = lua.load_scripts(&root); - info!(weapons, entities, failed, "Lua: скрипты GMod"); - if weapons > 0 { - log.push_back(format!("lua: оружия {weapons}, энтити {entities}")); - } - } - } - // Время для процедурной пробы скиннинга вьюмодели (A26). let mut wave_time = 0.0f32; // Физган: тело, которое держим, и расстояние удержания (A21/A23). @@ -549,32 +707,12 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { // `SOURCE_RS_PHYSGUN=1` — держать ближайшее тело без мыши: нужно, чтобы // проверять физган кадром и скриптом, а не руками. let auto_grab = std::env::var_os("SOURCE_RS_PHYSGUN").is_some(); - - // Анимация вьюмодели (A26): покой и выстрел. Имена у моделей разные - // (`v_pistol` — `idle01`/`fire`), поэтому берём первую существующую. - // - // ВАЖНО: `idle` у мировой модели оружия (`w_pistol`) — это поза - // **брошенного на землю** пистолета (RAWROT2 разворачивает его на бок), - // в руках она не годится: без `idle01` остаёмся в позе покоя, которую - // доворачивает `model_yaw`. - let (view_idle_animation, view_fire_animation, view_fire_duration) = - if let Some(index) = view_model_instance { - let w = world.borrow(); - let idle = ["idle01", "idle01empty"] - .into_iter() - .find(|name| w.model_has_animation(index, name)); - let fire = ["fire", "fire1", "attack_player"] - .into_iter() - .find(|name| w.model_has_animation(index, name)); - let duration = fire - .and_then(|name| w.model_animation_duration(index, name)) - .unwrap_or(0.4); - (idle, fire, duration) - } else { - (None, None, 0.4) - }; - // Отсчёт одноразовой анимации выстрела: сбрасывается по её длительности. - let mut view_fire_time: Option = None; + // `SOURCE_RS_TEST_ATTACK=1` — нажать ЛКМ на 10-м кадре и отпустить на + // 20-м: скриптовая проверка атаки SWEP без окна и мыши. + let test_attack = std::env::var_os("SOURCE_RS_TEST_ATTACK").is_some(); + // `SOURCE_RS_TEST_SWITCH=1` — каждые 10 кадров следующее оружие: проверка + // Holster/Deploy и пересоздания вьюмодели без клавиатуры. + let test_switch = std::env::var_os("SOURCE_RS_TEST_SWITCH").is_some(); // Покачивание вьюмодели при ходьбе и инерционный сдвиг при повороте // (лёгкий аналог `cl_bob`/viewmodel sway из Source). @@ -599,42 +737,64 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { mouse_btn: MouseButton::Left, .. } => { - // Физган: берём ближайшее тело под прицелом — по - // расстоянию вдоль луча, с допуском на толщину пропа. - 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)); - } + // Активный 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 let Some((along, id)) = best { - grabbed = Some((id, along, math::Vec3::ZERO)); - log.push_back(format!("физган: взял ({} юнитов)", along as i32)); + 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_fire_animation.is_some() { - // Под прицелом нет тела — ЛКМ стреляет: одноразовая - // анимация вьюмодели, без оружия и урона (A23). - view_fire_time = Some(0.0); - log.push_back("выстрел".to_string()); - while log.len() > 8 { - log.pop_front(); + } else { + // Физган: берём ближайшее тело под прицелом — по + // расстоянию вдоль луча, с допуском на толщину пропа. + let origin = player.eye_position(); + let dir = camera.forward(); + let w = world.borrow(); + let mut best: Option<(f32, engine::physics::BodyId)> = None; + for id in w.physics.body_list() { + let Some((position, _)) = w.physics.transform(id) else { + continue; + }; + let to_body = position - origin; + let along = to_body.dot(dir); + if !(8.0..=260.0).contains(&along) { + continue; + } + let perpendicular = (to_body - dir * along).length(); + if perpendicular > 40.0 { + continue; + } + if best.is_none_or(|(best_along, _)| along < best_along) { + best = Some((along, id)); + } + } + if let Some((along, id)) = best { + grabbed = Some((id, along, math::Vec3::ZERO)); + log.push_back(format!("физган: взял ({} юнитов)", along as i32)); + while log.len() > 8 { + log.pop_front(); + } + } else if view_model.fire.is_some() { + // Под прицелом нет тела — ЛКМ стреляет: одноразовая + // анимация вьюмодели, без оружия и урона (A23). + view_model.fire_time = Some(0.0); + log.push_back("выстрел".to_string()); + while log.len() > 8 { + log.pop_front(); + } } } } @@ -642,6 +802,9 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { mouse_btn: MouseButton::Left, .. } => { + if let Some(state) = swep.as_mut() { + state.primary_up(); + } if grabbed.take().is_some() { log.push_back("физган: отпустил".to_string()); while log.len() > 8 { @@ -649,6 +812,47 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } } } + Event::KeyDown { + keycode: Some(code), + repeat: false, + .. + } if weapon_slot_for_key(code).is_some() => { + let slot = weapon_slot_for_key(code).unwrap_or(1); + let switched = if let (Some(lua), Some(state)) = (lua.as_ref(), swep.as_mut()) { + state.switch_slot(lua, slot) + } else { + false + }; + if switched { + let model = desired_view_model(&view_model_override, swep.as_ref()); + let fallback = view_model_override.is_some() + || swep.as_ref().is_none_or(|state| state.active.is_none()); + view_model.set_model( + &world, + &vfs, + model.as_deref(), + player.eye_position(), + fallback, + ); + if let Some(name) = swep + .as_ref() + .and_then(|state| state.active_info()) + .map(|weapon| weapon.display_name().to_string()) + { + log.push_back(format!("swep: {name}")); + while log.len() > 8 { + log.pop_front(); + } + } + } else if swep.as_ref().is_some_and(|state| { + !state.weapons.iter().any(|weapon| weapon.slot == slot) + }) { + log.push_back(format!("slot {slot}: пусто")); + while log.len() > 8 { + log.pop_front(); + } + } + } Event::KeyDown { keycode: Some(Keycode::G), repeat: false, @@ -807,9 +1011,58 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } // Одноразовая анимация выстрела: тикает и гаснет по длительности. - if let Some(time) = view_fire_time { + if let Some(time) = view_model.fire_time { let time = time + dt; - view_fire_time = (time < view_fire_duration).then_some(time); + view_model.fire_time = (time < view_model.fire_duration).then_some(time); + } + // `SOURCE_RS_TEST_ATTACK`: ЛКМ на 10-м кадре, отпускание на 20-м. + // Автоматическое оружие успевает выстрелить несколько раз, обычное — + // один; ошибки скрипта видны в логе, но кадр не падает. + if test_attack && frames_drawn == 10 { + if let (Some(lua), Some(state)) = (lua.as_ref(), swep.as_mut()) { + info!( + weapon = state.active_class().unwrap_or("нет"), + "SWEP: тестовая атака (ЛКМ)" + ); + state.primary_down(lua); + } + } + if test_attack && frames_drawn == 20 { + if let Some(state) = swep.as_mut() { + state.primary_up(); + } + } + if test_switch && frames_drawn > 0 && frames_drawn % 10 == 0 { + let switched = if let (Some(lua), Some(state)) = (lua.as_ref(), swep.as_mut()) { + let count = state.weapons.len(); + if count == 0 { + false + } else { + let next = state.active.map_or(0, |index| (index + 1) % count); + state.switch_to(lua, next) + } + } else { + false + }; + if switched { + let model = desired_view_model(&view_model_override, swep.as_ref()); + let fallback = view_model_override.is_some() + || swep.as_ref().is_none_or(|state| state.active.is_none()); + view_model.set_model( + &world, + &vfs, + model.as_deref(), + player.eye_position(), + fallback, + ); + if let Some(name) = swep + .as_ref() + .and_then(|state| state.active_info()) + .map(|weapon| weapon.display_name().to_string()) + { + info!(weapon = %name, "SWEP: тестовое переключение"); + } + } } // Физика идёт до ходьбы игрока: игрок должен упираться в уже @@ -983,7 +1236,7 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { // (жалоба пользователя). Раньше это шло до `player.update`. { let mut w = world.borrow_mut(); - if let Some(index) = view_model_instance { + if let Some(index) = view_model.instance { let angles = math::Vec3::new(camera.pitch, camera.yaw, view_roll); // Базовая поза вьюмодели: чуть справа и ниже глаз, ствол вперёд. let eye = camera.pos; @@ -994,7 +1247,7 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { // +Y модели), а не прибавкой к мировому yaw: иначе при взгляде // вниз доворот превращается в крен (жалоба пользователя). let centre_local = - engine::model::rotate_model_vector(view_model_center, 0.0, view_yaw, 0.0); + engine::model::rotate_model_vector(view_model.center, 0.0, view_yaw, 0.0); let centre = engine::model::rotate_model_vector(centre_local, angles.x, angles.y, angles.z); // Смещения — в базисе камеры, а не в мировом: «вниз» мировым @@ -1012,9 +1265,9 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { let target = eye + forward * view_distance + right * lateral - up * vertical; let mut origin = target - centre; // Выстрел — одноразовая анимация; иначе покой. - let (animation, time) = match (view_fire_animation, view_fire_time) { + let (animation, time) = match (view_model.fire.as_deref(), view_model.fire_time) { (Some(name), Some(time)) => (name, time), - _ => (view_idle_animation.unwrap_or("idle01"), wave_time), + _ => (view_model.idle.as_deref().unwrap_or("idle01"), wave_time), }; if std::env::var("SOURCE_RS_VIEW_STATIC").is_ok() { w.move_model_instance(index, origin, angles); @@ -1123,6 +1376,22 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { renderer.upload_index_ranges(&gfx.queue, &w.indices, &moved_index_ranges); } + // Lua: таймеры, Think-хук и логика активного SWEP. До HUD: патроны + // и вывод скрипта должны попасть в этот кадр, а не в следующий. + if let Some(lua) = &lua { + let _ = lua.tick(); + let _ = lua.call_hook("Think", mlua::MultiValue::new()); + if let Some(state) = swep.as_mut() { + state.update(lua); + } + for line in lua.take_output() { + log.push_back(line); + while log.len() > 8 { + log.pop_front(); + } + } + } + // HUD (тень + текст, чтобы читалось на любом фоне). let (width, height) = gfx.size(); // Android рисует поверх всего экрана, включая системные панели: @@ -1171,6 +1440,22 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } ), ); + if let Some(state) = &swep { + let text = if let Some(weapon) = state.active_info() { + let mut text = format!("weapon: {}", weapon.display_name()); + if let Some(lua) = &lua + && let Some((clip, size)) = state.ammo(lua) + { + text.push_str(&format!(" {clip}/{size}")); + } + text + } else if state.weapons.is_empty() { + "weapon: pistol".to_string() + } else { + "weapon: pistol (0-9: SWEP slots)".to_string() + }; + hud(8.0, hud_top + 40.0, 1.4, [1.0, 0.9, 0.65, 1.0], text); + } hud( 8.0, hud_bottom, @@ -1179,7 +1464,7 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { if touch_enabled { "touch: left stick move | right swipe look | buttons jump/duck".to_string() } else { - "WASD move | Space jump | Ctrl duck | V noclip | ` log".to_string() + "WASD move | Space jump | Ctrl duck | V noclip | 0-9 weapon | ` log".to_string() }, ); if touch_enabled { @@ -1215,18 +1500,6 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } } - // Lua: таймеры и Think-хук. - if let Some(lua) = &lua { - let _ = lua.tick(); - let _ = lua.call_hook("Think", mlua::MultiValue::new()); - for line in lua.take_output() { - log.push_back(line); - while log.len() > 8 { - log.pop_front(); - } - } - } - if frames_drawn % 60 == 0 { tracing::debug!( z = player.position.z, diff --git a/crates/client/src/weapon.rs b/crates/client/src/weapon.rs new file mode 100644 index 0000000..fb00b4d --- /dev/null +++ b/crates/client/src/weapon.rs @@ -0,0 +1,351 @@ +//! SWEP-оружие клиента: реестр из Lua, слоты, атака и состояние. +//! +//! Скрипты (`lua/weapons/*.lua`) уже загружены в `glua`; здесь — клиентская +//! часть: какие из них можно выбрать, как переключать и как вызывать +//! `PrimaryAttack`/`Think`/`Deploy`, не роняя кадр ошибкой Lua. + +use fs::VirtualFs; +use glua::LuaRuntime; +use tracing::{debug, info, warn}; + +/// Оружие, доступное клиенту. +#[derive(Debug, Clone)] +pub struct WeaponInfo { + pub class: String, + /// `PrintName` без `#`-ключа локализации. + pub name: String, + /// Путь вьюмодели (существует в VFS). + pub view_model: String, + /// Слот GMod: 0 — кулаки, 1 — пистолеты, 5 — предметы. + pub slot: i32, + pub slot_pos: i32, + pub automatic: bool, + /// `Primary.ClipSize`; отрицательный — патроны не показываем. + pub clip_size: i32, + /// Задержка выстрела, если скрипт не выставил `SetNextPrimaryFire`. + pub primary_delay: f64, +} + +impl WeaponInfo { + pub fn display_name(&self) -> &str { + if self.name.is_empty() { + &self.class + } else { + &self.name + } + } +} + +/// Читает реестр `list.Get("Weapon")` и оставляет выбираемые SWEP. +/// +/// Фильтры: `Spawnable` истинно, `ViewModel` задан и файл модели есть в VFS. +/// Скрытые/админские оружия GMod (`AdminOnly`) не отсекаются: на локальном +/// клиенте они и должны быть доступны. +pub fn load(lua: &LuaRuntime, vfs: &VirtualFs) -> Vec { + let mut weapons = Vec::new(); + for class in lua.list_names("Weapon") { + if !lua.weapon_bool(&class, "Spawnable").unwrap_or(false) { + debug!(class = %class, "SWEP: пропуск (Spawnable=false)"); + continue; + } + let Some(view_model) = lua.weapon_string(&class, "ViewModel") else { + debug!(class = %class, "SWEP: пропуск (нет ViewModel)"); + continue; + }; + let view_model = view_model.trim().to_string(); + if view_model.is_empty() { + debug!(class = %class, "SWEP: пропуск (пустой ViewModel)"); + continue; + } + if !vfs.exists(&view_model) { + warn!(class = %class, model = %view_model, "SWEP: модели нет в VFS — пропуск"); + continue; + } + let name = lua + .weapon_string(&class, "PrintName") + .map(|name| name.trim_start_matches('#').trim().to_string()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| class.clone()); + let slot = lua.weapon_number(&class, "Slot").unwrap_or(1.0) as i32; + let slot_pos = lua.weapon_number(&class, "SlotPos").unwrap_or(0.0) as i32; + let automatic = lua + .weapon_bool(&class, "Primary.Automatic") + .unwrap_or(false); + let clip_size = lua + .weapon_number(&class, "Primary.ClipSize") + .unwrap_or(-1.0) as i32; + let primary_delay = lua + .weapon_number(&class, "Primary.Delay") + .filter(|delay| delay.is_finite() && *delay > 0.0) + .unwrap_or(0.15); + weapons.push(WeaponInfo { + class, + name, + view_model, + slot, + slot_pos, + automatic, + clip_size, + primary_delay, + }); + } + weapons.sort_by(|a, b| (a.slot, a.slot_pos, &a.class).cmp(&(b.slot, b.slot_pos, &b.class))); + weapons +} + +/// Находит оружие по классу (без учёта регистра). +pub fn find_class(weapons: &[WeaponInfo], class: &str) -> Option { + weapons + .iter() + .position(|weapon| weapon.class.eq_ignore_ascii_case(class)) +} + +/// Находит оружие в слоте: побеждает меньший `SlotPos`, затем имя класса. +pub fn find_slot(weapons: &[WeaponInfo], slot: i32) -> Option { + weapons + .iter() + .enumerate() + .filter(|(_, weapon)| weapon.slot == slot) + .min_by(|a, b| { + a.1.slot_pos + .cmp(&b.1.slot_pos) + .then_with(|| a.1.class.cmp(&b.1.class)) + }) + .map(|(index, _)| index) +} + +/// Активное оружие и его вызовы в Lua. +pub struct SwepState { + pub weapons: Vec, + pub active: Option, + /// ЛКМ удерживается (для автоматического огня). + firing: bool, + /// Время, раньше которого `PrimaryAttack` вызывать нельзя (`CurTime`). + next_primary: f64, + /// После ошибки `Think` не спамим лог каждый кадр. + think_failed: bool, +} + +impl SwepState { + /// Строит состояние и выбирает оружие из `SOURCE_RS_SWEP`. + /// + /// Без переменной активного SWEP нет: клиент остаётся с прежним + /// пистолетом-заглушкой и физганом, а оружие выбирается клавишами слотов. + pub fn new(lua: &LuaRuntime, vfs: &VirtualFs) -> Self { + let weapons = load(lua, vfs); + info!( + count = weapons.len(), + classes = ?weapons.iter().map(|w| w.class.as_str()).collect::>(), + "SWEP: реестр" + ); + let wanted = std::env::var("SOURCE_RS_SWEP").ok(); + let active = wanted.as_deref().and_then(|class| { + let class = class.trim(); + if class.is_empty() { + return None; + } + match find_class(&weapons, class) { + Some(index) => Some(index), + None => { + warn!(class, "SWEP: оружие из SOURCE_RS_SWEP не найдено"); + None + } + } + }); + let mut state = Self { + weapons, + active, + firing: false, + next_primary: 0.0, + think_failed: false, + }; + if let Some(index) = active { + let class = state.weapons[index].class.clone(); + lua.set_active_weapon(Some(&class)); + if let Err(err) = lua.call_weapon_method(&class, "Deploy") { + warn!(class = %class, "SWEP: Deploy не выполнен: {err}"); + } + state.next_primary = state.read_next_fire(lua); + info!(class = %class, "SWEP: активное оружие"); + } + state + } + + pub fn active_info(&self) -> Option<&WeaponInfo> { + self.active.and_then(|index| self.weapons.get(index)) + } + + pub fn active_class(&self) -> Option<&str> { + self.active_info().map(|weapon| weapon.class.as_str()) + } + + /// Модель вьюмодели активного оружия (без override). + pub fn active_view_model(&self) -> Option<&str> { + self.active_info().map(|weapon| weapon.view_model.as_str()) + } + + /// Переключает оружие по слоту GMod. Возвращает `true`, если сменилось. + pub fn switch_slot(&mut self, lua: &LuaRuntime, slot: i32) -> bool { + let Some(index) = find_slot(&self.weapons, slot) else { + debug!(slot, "SWEP: слот пуст"); + return false; + }; + self.switch_to(lua, index) + } + + /// Переключает на индекс реестра; вызывает `Holster` старого и `Deploy` + /// нового. Ошибки Lua логируются и не прерывают смену. + pub fn switch_to(&mut self, lua: &LuaRuntime, index: usize) -> bool { + if self.active == Some(index) || index >= self.weapons.len() { + return false; + } + if let Some(old) = self.active_class() { + if let Err(err) = lua.call_weapon_method(old, "Holster") { + warn!(class = %old, "SWEP: Holster не выполнен: {err}"); + } + } + self.active = Some(index); + self.firing = false; + self.think_failed = false; + let class = self.weapons[index].class.clone(); + lua.set_active_weapon(Some(&class)); + if let Err(err) = lua.call_weapon_method(&class, "Deploy") { + warn!(class = %class, "SWEP: Deploy не выполнен: {err}"); + } + self.next_primary = self.read_next_fire(lua); + info!(class = %class, "SWEP: оружие выбрано"); + true + } + + /// ЛКМ: для SWEP — `PrimaryAttack`, для пустого слота — `false` + /// (клиент оставляет прежнее поведение физгана/заглушки). + pub fn primary_down(&mut self, lua: &LuaRuntime) -> bool { + if self.active.is_none() { + return false; + } + self.firing = true; + self.try_primary(lua); + true + } + + pub fn primary_up(&mut self) { + self.firing = false; + } + + /// Кадр SWEP: `Think` и, для автоматического оружия, повторный огонь. + pub fn update(&mut self, lua: &LuaRuntime) { + let Some(index) = self.active else { + return; + }; + let class = self.weapons[index].class.clone(); + if !self.think_failed { + if let Err(err) = lua.call_weapon_method(&class, "Think") { + warn!(class = %class, "SWEP: Think не выполнен: {err}"); + self.think_failed = true; + } + } + if self.firing && self.weapons[index].automatic { + self.try_primary(lua); + } + } + + /// Патроны активного оружия: `(в магазине, размер магазина)`. + pub fn ammo(&self, lua: &LuaRuntime) -> Option<(i64, i64)> { + let weapon = self.active_info()?; + if weapon.clip_size < 0 { + return None; + } + let clip = lua + .call_weapon_number(&weapon.class, "Clip1") + .unwrap_or(0.0); + let clip = if clip.is_finite() { + clip.max(0.0) as i64 + } else { + 0 + }; + Some((clip, weapon.clip_size as i64)) + } + + fn try_primary(&mut self, lua: &LuaRuntime) -> bool { + let Some(index) = self.active else { + return false; + }; + let class = self.weapons[index].class.clone(); + let now = lua.cur_time(); + if now < self.next_primary { + return false; + } + if let Err(err) = lua.call_weapon_method(&class, "PrimaryAttack") { + warn!(class = %class, "SWEP: PrimaryAttack не выполнен: {err}"); + } + let next = lua + .call_weapon_number(&class, "GetNextPrimaryFire") + .unwrap_or(0.0); + let delay = self.weapons[index].primary_delay.max(0.01); + // `inf` от деления на ноль в скрипте не должен запирать оружие + // навсегда: считаем его отсутствием задержки. + self.next_primary = if next.is_finite() && next > now { + next + } else { + now + delay + }; + debug!(class = %class, next = self.next_primary, "SWEP: PrimaryAttack"); + true + } + + fn read_next_fire(&self, lua: &LuaRuntime) -> f64 { + let Some(class) = self.active_class() else { + return 0.0; + }; + let next = lua + .call_weapon_number(class, "GetNextPrimaryFire") + .unwrap_or(0.0); + if next.is_finite() { next } else { 0.0 } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn weapon(class: &str, slot: i32, slot_pos: i32) -> WeaponInfo { + WeaponInfo { + class: class.to_string(), + name: class.to_string(), + view_model: "models/weapons/c_arms.mdl".to_string(), + slot, + slot_pos, + automatic: false, + clip_size: -1, + primary_delay: 0.15, + } + } + + #[test] + fn slot_prefers_lower_slot_position() { + let weapons = vec![ + weapon("weapon_medkit", 5, 3), + weapon("weapon_pistol", 1, 2), + weapon("weapon_smg", 1, 1), + ]; + assert_eq!(find_slot(&weapons, 1), Some(2), "SlotPos=1 побеждает"); + assert_eq!(find_slot(&weapons, 5), Some(0)); + assert_eq!(find_slot(&weapons, 9), None); + } + + #[test] + fn class_lookup_ignores_case() { + let weapons = vec![weapon("weapon_fists", 0, 4)]; + assert_eq!(find_class(&weapons, "WEAPON_FISTS"), Some(0)); + assert_eq!(find_class(&weapons, "weapon_medkit"), None); + } + + #[test] + fn display_name_is_trimmed_at_load() { + let mut info = weapon("weapon_fists", 0, 4); + info.name = "weapon_fists".to_string(); + assert_eq!(info.display_name(), "weapon_fists"); + info.name.clear(); + assert_eq!(info.display_name(), "weapon_fists"); + } +}