Клиент: выбор 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:
parent
a46e4d827d
commit
1fe36b6869
3 changed files with 839 additions and 214 deletions
|
|
@ -10,6 +10,7 @@ mod menu;
|
|||
mod net_cmd;
|
||||
mod pack;
|
||||
mod touch;
|
||||
mod weapon;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
|
|
|||
|
|
@ -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
351
crates/client/src/weapon.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue