Клиент: выбор SWEP, слоты, атака и HUD

- 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 в методах оружия логируется, кадр не падает.
This commit is contained in:
Niko Marmeladkov 2026-10-24 13:29:41 +03:00
parent a46e4d827d
commit 1fe36b6869
3 changed files with 839 additions and 214 deletions

View file

@ -10,6 +10,7 @@ mod menu;
mod net_cmd;
mod pack;
mod touch;
mod weapon;
use clap::{Parser, Subcommand};
use std::path::PathBuf;

View file

@ -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<std::cell::RefCell<engine::world::World>>,
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<u32> = 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<usize>,
center: math::Vec3,
idle: Option<String>,
fire: Option<String>,
fire_duration: f32,
/// Отсчёт одноразовой анимации выстрела.
fire_time: Option<f32>,
}
impl ViewModelState {
fn new(
world: &std::rc::Rc<std::cell::RefCell<engine::world::World>>,
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<std::cell::RefCell<engine::world::World>>,
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<std::cell::RefCell<engine::world::World>>,
) {
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<i32> {
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<Option<String>>,
swep: Option<&crate::weapon::SwepState>,
) -> Option<String> {
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<PathBuf>, 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<u32> = 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<String> = 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<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
.ok()
.and_then(|value| value.trim().parse().ok())
.unwrap_or(26.0);
let mut log: std::collections::VecDeque<String> = 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<PathBuf>, 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<f32> = 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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,

351
crates/client/src/weapon.rs Normal file
View file

@ -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<WeaponInfo> {
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<usize> {
weapons
.iter()
.position(|weapon| weapon.class.eq_ignore_ascii_case(class))
}
/// Находит оружие в слоте: побеждает меньший `SlotPos`, затем имя класса.
pub fn find_slot(weapons: &[WeaponInfo], slot: i32) -> Option<usize> {
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<WeaponInfo>,
pub active: Option<usize>,
/// ЛКМ удерживается (для автоматического огня).
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::<Vec<_>>(),
"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");
}
}