Вьюмодель: посадка по модели и встроенный weapon_pistol
Посадка вьюмодели вынесена в таблицу по модели: у w_pistol ствол вдоль +Y, у v_pistol/c_smg1 — вдоль +X, поэтому общие view_yaw/дистанция не работали. В движке появился масштаб экземпляра и полный доворот модели; для w_pistol/v_pistol/c_smg1 подобраны дистанция, смещение, масштаб и yaw, near-плоскость не режет ни одну модель.
weapon_pistol в HL2 — движковое оружие, его Lua-скрипта в ассетах нет. Добавлен встроенный SWEP (Slot 1/SlotPos 1, 18 патронов, одиночный огонь) с регистрацией в weapons и list.Get("Weapon"); пользовательский lua/weapons/weapon_pistol.lua важнее встроенного. ViewModelFOV и необязательные ViewModelOffset/Angles/Scale из SWEP перекрывают таблицу.
Проверено кадрами на Xvfb: пистолет и флечетган в кадре, ближе_near=0, выстрел 18→17, слот 1 после флечетгана и кулаков возвращает пистолет, c_arms по-прежнему скрывается. Добавлен SOURCE_RS_TEST_SLOT для синтетической клавиши слота (xdotool не доставляет клавиши в SDL-окно на Xvfb).
This commit is contained in:
parent
21abe0d4b7
commit
32aad74eb5
11 changed files with 1114 additions and 152 deletions
|
|
@ -13,6 +13,7 @@ mod qmenu;
|
|||
mod sandbox;
|
||||
mod touch;
|
||||
mod ui_overlay;
|
||||
mod viewmodel;
|
||||
mod weapon;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ fn spawn_view_model(
|
|||
name: &str,
|
||||
eye: math::Vec3,
|
||||
allow_fallback: bool,
|
||||
) -> Option<(usize, math::Vec3)> {
|
||||
) -> Option<(usize, math::Vec3, String)> {
|
||||
let mut candidates = vec![name.to_string()];
|
||||
if allow_fallback {
|
||||
candidates.push(DEFAULT_VIEW_MODEL.to_string());
|
||||
|
|
@ -137,7 +137,7 @@ fn spawn_view_model(
|
|||
.unwrap_or(false);
|
||||
info!(batch = ?batch, ?sample, lo, hi, inside, "индексы вьюмодели");
|
||||
}
|
||||
return Some((index, centre));
|
||||
return Some((index, centre, candidate));
|
||||
}
|
||||
warn!(model = %name, "вьюмодель не загрузилась");
|
||||
None
|
||||
|
|
@ -147,6 +147,8 @@ fn spawn_view_model(
|
|||
struct ViewModelState {
|
||||
instance: Option<usize>,
|
||||
center: math::Vec3,
|
||||
/// Посадка под конкретную модель: дистанция, масштаб, доворот.
|
||||
placement: crate::viewmodel::Placement,
|
||||
idle: Option<String>,
|
||||
fire: Option<String>,
|
||||
fire_duration: f32,
|
||||
|
|
@ -161,16 +163,18 @@ impl ViewModelState {
|
|||
model: Option<&str>,
|
||||
eye: math::Vec3,
|
||||
allow_fallback: bool,
|
||||
tuning: Option<&crate::viewmodel::ViewModelTuning>,
|
||||
) -> Self {
|
||||
let mut state = Self {
|
||||
instance: None,
|
||||
center: math::Vec3::ZERO,
|
||||
placement: crate::viewmodel::Placement::default(),
|
||||
idle: None,
|
||||
fire: None,
|
||||
fire_duration: 0.4,
|
||||
fire_time: None,
|
||||
};
|
||||
state.set_model(world, vfs, model, eye, allow_fallback);
|
||||
state.set_model(world, vfs, model, eye, allow_fallback, tuning);
|
||||
state
|
||||
}
|
||||
|
||||
|
|
@ -184,6 +188,7 @@ impl ViewModelState {
|
|||
model: Option<&str>,
|
||||
eye: math::Vec3,
|
||||
allow_fallback: bool,
|
||||
tuning: Option<&crate::viewmodel::ViewModelTuning>,
|
||||
) {
|
||||
if let Some(old) = self.instance.take() {
|
||||
world.borrow_mut().move_model_instance(
|
||||
|
|
@ -193,6 +198,7 @@ impl ViewModelState {
|
|||
);
|
||||
}
|
||||
self.center = math::Vec3::ZERO;
|
||||
self.placement = crate::viewmodel::Placement::default();
|
||||
self.idle = None;
|
||||
self.fire = None;
|
||||
self.fire_duration = 0.4;
|
||||
|
|
@ -200,11 +206,23 @@ impl ViewModelState {
|
|||
let Some(model) = model else {
|
||||
return;
|
||||
};
|
||||
let Some((index, center)) = spawn_view_model(world, vfs, model, eye, allow_fallback) else {
|
||||
let Some((index, center, actual)) =
|
||||
spawn_view_model(world, vfs, model, eye, allow_fallback)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.instance = Some(index);
|
||||
self.center = center;
|
||||
// Посадка — по фактически загруженной модели: при откате
|
||||
// (`v_physgun` → `w_pistol`) доворот и масштаб должны быть от
|
||||
// пистолета, а не от отсутствующего файла.
|
||||
self.placement = crate::viewmodel::for_model_with_env(&actual, tuning);
|
||||
if !world
|
||||
.borrow_mut()
|
||||
.set_model_instance_scale(index, self.placement.scale)
|
||||
{
|
||||
warn!(scale = self.placement.scale, model = %actual, "вьюмодель: масштаб не задан");
|
||||
}
|
||||
self.refresh_animations(world);
|
||||
}
|
||||
|
||||
|
|
@ -254,6 +272,23 @@ fn weapon_slot_for_key(code: Keycode) -> Option<i32> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Обратное преобразование для тестового хука `SOURCE_RS_TEST_SLOT`.
|
||||
fn weapon_keycode_for_slot(slot: i32) -> Option<Keycode> {
|
||||
match slot {
|
||||
0 => Some(Keycode::_0),
|
||||
1 => Some(Keycode::_1),
|
||||
2 => Some(Keycode::_2),
|
||||
3 => Some(Keycode::_3),
|
||||
4 => Some(Keycode::_4),
|
||||
5 => Some(Keycode::_5),
|
||||
6 => Some(Keycode::_6),
|
||||
7 => Some(Keycode::_7),
|
||||
8 => Some(Keycode::_8),
|
||||
9 => Some(Keycode::_9),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Модель для текущего состояния: override отладки важнее физгана и SWEP.
|
||||
fn desired_view_model(
|
||||
override_path: &Option<Option<String>>,
|
||||
|
|
@ -273,6 +308,22 @@ fn desired_view_model(
|
|||
}
|
||||
}
|
||||
|
||||
/// Поля SWEP, перекрывающие посадку вьюмодели (если оружие активно).
|
||||
///
|
||||
/// В режиме физгана SWEP-посадка не применяется: в руках заглушка
|
||||
/// (`v_physgun` → `w_pistol`), и её доворот не должен зависеть от оружия,
|
||||
/// которое сейчас «за спиной».
|
||||
fn active_view_tuning(
|
||||
swep: Option<&crate::weapon::SwepState>,
|
||||
physgun_active: bool,
|
||||
) -> Option<&crate::viewmodel::ViewModelTuning> {
|
||||
if physgun_active {
|
||||
return None;
|
||||
}
|
||||
swep.and_then(crate::weapon::SwepState::active_info)
|
||||
.map(|weapon| &weapon.view_model_tuning)
|
||||
}
|
||||
|
||||
/// Запрос на спавн пропа из меню или клавиши G.
|
||||
struct SpawnRequest {
|
||||
model: String,
|
||||
|
|
@ -549,6 +600,14 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
if weapons > 0 {
|
||||
log.push_back(format!("lua: оружия {weapons}, энтити {entities}"));
|
||||
}
|
||||
// `weapon_pistol` в HL2 — движковое оружие, в ассетах GMod его
|
||||
// Lua-скрипта нет. Встроенная реализация регистрируется только если
|
||||
// оригинального скрипта не нашлось.
|
||||
let builtin = lua.load_builtin_weapons();
|
||||
if builtin > 0 {
|
||||
info!(builtin, "SWEP: встроенные оружия");
|
||||
log.push_back(format!("lua: встроенных оружий {builtin}"));
|
||||
}
|
||||
|
||||
// Настоящий клиентский UI: Derma, VGUI, `spawnmenu` и геймод
|
||||
// Sandbox. `OnGamemodeLoaded` создаёт `g_SpawnMenu`; его наличие —
|
||||
|
|
@ -640,6 +699,7 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
initial_view_model.as_deref(),
|
||||
player.eye_position(),
|
||||
view_model_fallback,
|
||||
active_view_tuning(swep.as_ref(), physgun_active),
|
||||
);
|
||||
|
||||
// `--spawn <модель>` — спавн пропов на старте (отладка Q-меню, A24).
|
||||
|
|
@ -779,25 +839,10 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
let mut fps_window_frames: u32 = 0;
|
||||
let mut fps_window_seconds = 0.0f32;
|
||||
let mut fps_worst_ms = 0.0f32;
|
||||
// Крен вьюмодели: модели оружия в мире лежат на боку, и в руках их надо
|
||||
// довернуть. Значение подбирается замером (кадры с разным креном).
|
||||
let view_roll: f32 = std::env::var("SOURCE_RS_VIEW_ROLL")
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse().ok())
|
||||
.unwrap_or(0.0);
|
||||
// Курс: у модели оружия ствол смотрит вдоль +Y (влево), а не +X, поэтому
|
||||
// доворачиваем на -90°, иначе пистолет лежит поперёк кадра.
|
||||
let view_yaw: f32 = std::env::var("SOURCE_RS_VIEW_YAW")
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse().ok())
|
||||
.unwrap_or(-90.0);
|
||||
// Дистанция вьюмодели от глаз — подбирается замером (у Source своя
|
||||
// проекция для вьюмодели, у нас общая, поэтому держим её дальше).
|
||||
// Посадка вьюмодели теперь в `viewmodel::Placement` и подбирается по
|
||||
// модели (дистанция/масштаб/доворот); здесь остаётся только флаг
|
||||
// однократного отладочного лога.
|
||||
let mut view_debug_logged = false;
|
||||
let view_distance: f32 = std::env::var("SOURCE_RS_VIEW_DIST")
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse().ok())
|
||||
.unwrap_or(26.0);
|
||||
// Q-меню спавна (A24): предметы из реестров Lua и spawnlist GMod.
|
||||
// Собирается один раз на старте: списки меняются только загрузкой карты.
|
||||
// Если Lua-меню Sandbox загрузилось, этот список — только fallback и для
|
||||
|
|
@ -883,9 +928,37 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
// не должны аллоцировать Vec каждый кадр.
|
||||
let mut debug_extra: Vec<engine::DebugLine> = Vec::new();
|
||||
let mut frozen_markers: Vec<math::Vec3> = Vec::new();
|
||||
// `SOURCE_RS_TEST_SLOT=<0..9>` — синтетическое нажатие клавиши слота на
|
||||
// кадре `SOURCE_RS_TEST_SLOT_FRAME` (по умолчанию 30). Нужно для проверки
|
||||
// смены оружия на Xvfb, где xdotool не доставляет клавиши в SDL-окно.
|
||||
let test_slot = std::env::var("SOURCE_RS_TEST_SLOT")
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<i32>().ok())
|
||||
.filter(|slot| (0..=9).contains(slot));
|
||||
let test_slot_frame: u64 = std::env::var("SOURCE_RS_TEST_SLOT_FRAME")
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse().ok())
|
||||
.unwrap_or(30);
|
||||
|
||||
'running: loop {
|
||||
for event in events.poll_iter() {
|
||||
let mut frame_events: Vec<Event> = events.poll_iter().collect();
|
||||
if frames_drawn == test_slot_frame
|
||||
&& let Some(slot) = test_slot
|
||||
&& let Some(code) = weapon_keycode_for_slot(slot)
|
||||
{
|
||||
info!(slot, frame = frames_drawn, "SWEP: тестовое нажатие слота");
|
||||
frame_events.push(Event::KeyDown {
|
||||
timestamp: 0,
|
||||
window_id: 0,
|
||||
keycode: Some(code),
|
||||
scancode: None,
|
||||
keymod: sdl3::keyboard::Mod::NOMOD,
|
||||
repeat: false,
|
||||
which: 0,
|
||||
raw: 0,
|
||||
});
|
||||
}
|
||||
for event in frame_events {
|
||||
match event {
|
||||
Event::Quit { .. } => break 'running,
|
||||
Event::KeyDown {
|
||||
|
|
@ -1120,6 +1193,7 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
model.as_deref(),
|
||||
player.eye_position(),
|
||||
fallback,
|
||||
active_view_tuning(swep.as_ref(), physgun_active),
|
||||
);
|
||||
if let Some(name) = swep
|
||||
.as_ref()
|
||||
|
|
@ -1209,6 +1283,7 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
model.as_deref(),
|
||||
player.eye_position(),
|
||||
fallback,
|
||||
active_view_tuning(swep.as_ref(), physgun_active),
|
||||
);
|
||||
log.push_back(if physgun_active {
|
||||
"физган: включён (ЛКМ, ПКМ freeze, колесо)".to_string()
|
||||
|
|
@ -1638,6 +1713,7 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
model.as_deref(),
|
||||
player.eye_position(),
|
||||
fallback,
|
||||
active_view_tuning(swep.as_ref(), physgun_active),
|
||||
);
|
||||
if let Some(name) = swep
|
||||
.as_ref()
|
||||
|
|
@ -1882,17 +1958,24 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
{
|
||||
let mut w = world.borrow_mut();
|
||||
if let Some(index) = view_model.instance {
|
||||
let angles = math::Vec3::new(camera.pitch, camera.yaw, view_roll);
|
||||
// Базовая поза вьюмодели: чуть справа и ниже глаз, ствол вперёд.
|
||||
let placement = view_model.placement;
|
||||
let angles = math::Vec3::new(camera.pitch, camera.yaw, 0.0);
|
||||
// Базовая поза вьюмодели: своя для каждой модели (ствол вдоль
|
||||
// +Y у `w_*`, вдоль +X у `v_*`/`c_*`), поэтому доворот и
|
||||
// габарит берутся из таблицы посадки, а не из общих констант.
|
||||
let eye = camera.pos;
|
||||
let forward = camera.forward();
|
||||
let (sin_yaw, cos_yaw) = camera.yaw.to_radians().sin_cos();
|
||||
let right = math::Vec3::new(sin_yaw, -cos_yaw, 0.0);
|
||||
// Доворот модели — в её собственных координатах (ствол вдоль
|
||||
// +Y модели), а не прибавкой к мировому yaw: иначе при взгляде
|
||||
// вниз доворот превращается в крен (жалоба пользователя).
|
||||
let centre_local =
|
||||
engine::model::rotate_model_vector(view_model.center, 0.0, view_yaw, 0.0);
|
||||
// Доворот и масштаб — в координатах модели, затем поворот
|
||||
// камеры. Порядок важен: доворот по мировому yaw при взгляде
|
||||
// вниз превращался в крен (жалоба пользователя).
|
||||
let centre_local = engine::model::rotate_model_vector(
|
||||
view_model.center * placement.scale,
|
||||
placement.angles.x,
|
||||
placement.angles.y,
|
||||
placement.angles.z,
|
||||
);
|
||||
let centre =
|
||||
engine::model::rotate_model_vector(centre_local, angles.x, angles.y, angles.z);
|
||||
// Смещения — в базисе камеры, а не в мировом: «вниз» мировым
|
||||
|
|
@ -1905,9 +1988,10 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
0.0,
|
||||
);
|
||||
// Покачивание и инерция взгляда — в базисе камеры.
|
||||
let lateral = 8.0 + bob_right + view_sway_right;
|
||||
let vertical = 8.0 - bob_up - view_sway_up;
|
||||
let target = eye + forward * view_distance + right * lateral - up * vertical;
|
||||
let target = eye
|
||||
+ forward * placement.distance
|
||||
+ right * (placement.right + bob_right + view_sway_right)
|
||||
+ up * (placement.up + bob_up + view_sway_up);
|
||||
let mut origin = target - centre;
|
||||
// Выстрел — одноразовая анимация; иначе покой.
|
||||
let (animation, time) = match (view_model.fire.as_deref(), view_model.fire_time) {
|
||||
|
|
@ -1915,9 +1999,16 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
_ => (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);
|
||||
w.move_model_instance_oriented(index, origin, angles, placement.angles);
|
||||
} else {
|
||||
w.play_model_animation(index, origin, angles, animation, time, view_yaw);
|
||||
w.play_model_animation_oriented(
|
||||
index,
|
||||
origin,
|
||||
angles,
|
||||
animation,
|
||||
time,
|
||||
placement.angles,
|
||||
);
|
||||
}
|
||||
// Анимация с RAWPOS корня сдвигает весь меш (у `v_pistol` —
|
||||
// вниз на 68 юнитов): возвращаем центр позы в точку прицела,
|
||||
|
|
@ -1927,24 +2018,34 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
let correction = target - posed_centre;
|
||||
if correction.length_squared() > 1.0 {
|
||||
origin += correction;
|
||||
w.play_model_animation(index, origin, angles, animation, time, view_yaw);
|
||||
w.play_model_animation_oriented(
|
||||
index,
|
||||
origin,
|
||||
angles,
|
||||
animation,
|
||||
time,
|
||||
placement.angles,
|
||||
);
|
||||
}
|
||||
}
|
||||
if std::env::var("SOURCE_RS_VIEW_DEBUG").is_ok() && !view_debug_logged {
|
||||
view_debug_logged = true;
|
||||
info!(
|
||||
view_yaw,
|
||||
view_roll,
|
||||
view_distance,
|
||||
distance = placement.distance,
|
||||
scale = placement.scale,
|
||||
right = placement.right,
|
||||
up = placement.up,
|
||||
angles = ?placement.angles,
|
||||
animation,
|
||||
time,
|
||||
pitch = camera.pitch,
|
||||
yaw = camera.yaw,
|
||||
"вьюмодель: параметры"
|
||||
);
|
||||
// Габарит уже посчитанной позы в осях камеры: если ствол
|
||||
// смотрит вперёд, глубина ~10,6 и ширина ~1,7; если вбок —
|
||||
// наоборот. Меряем ПОСЛЕ скиннинга, иначе видим старый кадр.
|
||||
// Габарит уже посчитанной позы в осях камеры: самый
|
||||
// длинный размер должен быть «вперёд» (ствол смотрит от
|
||||
// камеры), а ближайшая вершина — дальше near-плоскости.
|
||||
// Меряем ПОСЛЕ скиннинга, иначе видим старый кадр.
|
||||
if let Some(instance) = w.model_instances.get(index) {
|
||||
let first = instance.first_vertex as usize;
|
||||
let count = instance.vertex_count as usize;
|
||||
|
|
@ -1964,9 +2065,13 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
);
|
||||
let mut min = [f32::MAX; 3];
|
||||
let mut max = [f32::MIN; 3];
|
||||
let mut clipped = 0usize;
|
||||
for v in verts {
|
||||
let p = math::Vec3::new(v.pos[0], v.pos[1], v.pos[2]) - eye;
|
||||
let axes = [p.dot(forward), p.dot(right), p.dot(up)];
|
||||
if axes[0] < camera.near {
|
||||
clipped += 1;
|
||||
}
|
||||
for j in 0..3 {
|
||||
min[j] = min[j].min(axes[j]);
|
||||
max[j] = max[j].max(axes[j]);
|
||||
|
|
@ -1976,6 +2081,8 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
вперёд = max[0] - min[0],
|
||||
вправо = max[1] - min[1],
|
||||
вверх = max[2] - min[2],
|
||||
near = camera.near,
|
||||
ближе_near = clipped,
|
||||
"вьюмодель: габарит в осях камеры"
|
||||
);
|
||||
}
|
||||
|
|
@ -2648,3 +2755,19 @@ fn parse_angles(text: &str) -> Option<(f32, f32)> {
|
|||
let yaw = parts.next()?.parse().ok()?;
|
||||
Some((pitch, yaw))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slot_keys_match_gmod() {
|
||||
for slot in 0..=9 {
|
||||
let Some(code) = weapon_keycode_for_slot(slot) else {
|
||||
panic!("слот {slot} без клавиши");
|
||||
};
|
||||
assert_eq!(weapon_slot_for_key(code), Some(slot));
|
||||
}
|
||||
assert_eq!(weapon_slot_for_key(Keycode::A), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
285
crates/client/src/viewmodel.rs
Normal file
285
crates/client/src/viewmodel.rs
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
//! Посадка вьюмодели: у каждой модели своя дистанция, масштаб и доворот.
|
||||
//!
|
||||
//! Source рисует вьюмодель отдельной проекцией (`ViewModelFOV` ≈ 54°), а у
|
||||
//! нас она идёт тем же проходом, что мир, с FOV камеры (по умолчанию 100°).
|
||||
//! Поэтому общие константы не работают: у `w_pistol` ствол вдоль +Y модели,
|
||||
//! у `v_pistol` и `c_smg1` — вдоль +X, а габариты отличаются в разы. Здесь
|
||||
//! хранится таблица «модель → посадка»; значения подобраны кадрами.
|
||||
//!
|
||||
//! `SWEP.ViewModelOffset`/`ViewModelAngles`/`ViewModelScale` из Lua-скрипта
|
||||
//! важнее таблицы (правило «оригинальные ресурсы — источник истины»), а
|
||||
//! `ViewModelFOV` пересчитывается в поправку масштаба: узкий FOV в Source
|
||||
//! увеличивает модель, широкий — уменьшает.
|
||||
|
||||
use math::Vec3;
|
||||
|
||||
/// Посадка модели в первом лице.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Placement {
|
||||
/// Смещение вправо от оси взгляда, юниты.
|
||||
pub right: f32,
|
||||
/// Смещение вверх (отрицательное — вниз), юниты.
|
||||
pub up: f32,
|
||||
/// Дистанция вперёд от глаз, юниты.
|
||||
pub distance: f32,
|
||||
/// Равномерный масштаб геометрии.
|
||||
pub scale: f32,
|
||||
/// Доворот в координатах модели: pitch, yaw, roll (градусы).
|
||||
pub angles: Vec3,
|
||||
}
|
||||
|
||||
impl Default for Placement {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
right: 9.0,
|
||||
up: -9.0,
|
||||
distance: 34.0,
|
||||
scale: 0.9,
|
||||
angles: Vec3::new(0.0, 0.0, 0.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Необязательные поля SWEP, перекрывающие таблицу.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct ViewModelTuning {
|
||||
/// `SWEP.ViewModelFOV`; пересчитывается в множитель масштаба.
|
||||
pub fov: Option<f32>,
|
||||
/// `SWEP.ViewModelOffset` в координатах Source: X вперёд, Y влево, Z вверх.
|
||||
pub offset: Option<Vec3>,
|
||||
/// `SWEP.ViewModelAngles`: pitch, yaw, roll.
|
||||
pub angles: Option<Vec3>,
|
||||
/// `SWEP.ViewModelScale`.
|
||||
pub scale: Option<f32>,
|
||||
}
|
||||
|
||||
impl ViewModelTuning {
|
||||
/// Применяет поля SWEP поверх таблицы.
|
||||
pub fn apply(&self, mut placement: Placement) -> Placement {
|
||||
if let Some(fov) = self.fov.filter(|fov| fov.is_finite() && *fov > 1.0) {
|
||||
// Опорный FOV — 54° (типичный ViewModelFOV HL2). Уже — модель
|
||||
// крупнее, шире — мельче; ограничиваем, чтобы кривой скрипт не
|
||||
// растянул оружие на пол-экрана.
|
||||
let reference = (54.0f32 * 0.5).to_radians().tan();
|
||||
let actual = (fov.clamp(10.0, 170.0) * 0.5).to_radians().tan();
|
||||
let factor = (reference / actual).clamp(0.45, 1.9);
|
||||
placement.scale = (placement.scale * factor).clamp(0.05, 20.0);
|
||||
}
|
||||
if let Some(offset) = self
|
||||
.offset
|
||||
.filter(|offset| offset.x.is_finite() && offset.y.is_finite() && offset.z.is_finite())
|
||||
{
|
||||
// Границы — от near-плоскости и разумного кадра: скрипт не должен
|
||||
// утащить оружие за камеру или на километр.
|
||||
placement.distance = offset.x.clamp(5.0, 500.0);
|
||||
placement.right = (-offset.y).clamp(-500.0, 500.0);
|
||||
placement.up = offset.z.clamp(-500.0, 500.0);
|
||||
}
|
||||
if let Some(angles) = self
|
||||
.angles
|
||||
.filter(|angles| angles.x.is_finite() && angles.y.is_finite() && angles.z.is_finite())
|
||||
{
|
||||
placement.angles = angles;
|
||||
}
|
||||
if let Some(scale) = self.scale.filter(|scale| scale.is_finite() && *scale > 0.0) {
|
||||
placement.scale = scale.clamp(0.05, 20.0);
|
||||
}
|
||||
placement
|
||||
}
|
||||
}
|
||||
|
||||
/// Посадка по имени модели (путь нормализуется: `\`, регистр, `.mdl`).
|
||||
pub fn for_model(model: &str, tuning: Option<&ViewModelTuning>) -> Placement {
|
||||
let name = engine::model::normalize_model_name(model);
|
||||
let mut placement = match name.as_str() {
|
||||
// Мировая модель пистолета: ствол вдоль +Y, габарит ~10.6×1.7×6.6.
|
||||
"models/weapons/w_pistol.mdl" => Placement {
|
||||
right: 14.0,
|
||||
up: -9.0,
|
||||
distance: 20.0,
|
||||
scale: 1.0,
|
||||
angles: Vec3::new(0.0, -90.0, 0.0),
|
||||
},
|
||||
// Вьюмодель HL2: ствол вдоль +X, но руки разнесены широко
|
||||
// (габарит ~38×23×25) — держим дальше и уменьшаем.
|
||||
"models/weapons/v_pistol.mdl" => Placement {
|
||||
right: 8.0,
|
||||
up: -10.0,
|
||||
distance: 34.0,
|
||||
scale: 0.8,
|
||||
angles: Vec3::new(0.0, 0.0, 0.0),
|
||||
},
|
||||
// Вьюмодель SMG из скрипта флечетгана: ствол вдоль +X,
|
||||
// габарит ~21.8×1.6×10 — чуть дальше пистолета.
|
||||
"models/weapons/c_smg1.mdl" => Placement {
|
||||
right: 14.0,
|
||||
up: -10.0,
|
||||
distance: 26.0,
|
||||
scale: 1.0,
|
||||
angles: Vec3::new(0.0, 0.0, 0.0),
|
||||
},
|
||||
// У кулаков (`c_arms.mdl`) геометрии нет — значения не важны,
|
||||
// но пусть будут как у пистолета, чтобы не прыгало при отладке.
|
||||
"models/weapons/c_arms.mdl" => Placement {
|
||||
right: 8.0,
|
||||
up: -8.0,
|
||||
distance: 30.0,
|
||||
scale: 1.0,
|
||||
angles: Vec3::new(0.0, 0.0, 0.0),
|
||||
},
|
||||
_ => {
|
||||
// Эвристика для остальных: у мировых моделей (`w_*`) ствол вдоль
|
||||
// +Y, у вьюмоделей (`v_*`, `c_*`) — вдоль +X.
|
||||
let file = name.rsplit('/').next().unwrap_or(&name);
|
||||
let yaw = if file.starts_with("w_") { -90.0 } else { 0.0 };
|
||||
Placement {
|
||||
angles: Vec3::new(0.0, yaw, 0.0),
|
||||
..Placement::default()
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(tuning) = tuning {
|
||||
placement = tuning.apply(placement);
|
||||
}
|
||||
placement
|
||||
}
|
||||
|
||||
/// Та же посадка с отладочными переопределениями из окружения.
|
||||
pub fn for_model_with_env(model: &str, tuning: Option<&ViewModelTuning>) -> Placement {
|
||||
apply_env(for_model(model, tuning))
|
||||
}
|
||||
|
||||
/// Отладочные переопределения для подбора кадров.
|
||||
///
|
||||
/// `SOURCE_RS_VIEW_YAW/PITCH/ROLL` — доворот модели, `SOURCE_RS_VIEW_DIST` —
|
||||
/// дистанция, `SOURCE_RS_VIEW_SCALE` — масштаб, `SOURCE_RS_VIEW_RIGHT/UP` —
|
||||
/// смещение в базисе камеры.
|
||||
fn apply_env(mut placement: Placement) -> Placement {
|
||||
if let Some(value) = env_f32("SOURCE_RS_VIEW_YAW") {
|
||||
placement.angles.y = value;
|
||||
}
|
||||
if let Some(value) = env_f32("SOURCE_RS_VIEW_PITCH") {
|
||||
placement.angles.x = value;
|
||||
}
|
||||
if let Some(value) = env_f32("SOURCE_RS_VIEW_ROLL") {
|
||||
placement.angles.z = value;
|
||||
}
|
||||
if let Some(value) = env_f32("SOURCE_RS_VIEW_DIST").filter(|value| *value > 1.0) {
|
||||
placement.distance = value;
|
||||
}
|
||||
if let Some(value) = env_f32("SOURCE_RS_VIEW_SCALE").filter(|value| *value > 0.0) {
|
||||
placement.scale = value;
|
||||
}
|
||||
if let Some(value) = env_f32("SOURCE_RS_VIEW_RIGHT") {
|
||||
placement.right = value;
|
||||
}
|
||||
if let Some(value) = env_f32("SOURCE_RS_VIEW_UP") {
|
||||
placement.up = value;
|
||||
}
|
||||
placement
|
||||
}
|
||||
|
||||
fn env_f32(name: &str) -> Option<f32> {
|
||||
let value = std::env::var(name).ok()?;
|
||||
let value = value.trim().parse::<f32>().ok()?;
|
||||
value.is_finite().then_some(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn models_use_their_own_axis_and_size() {
|
||||
let pistol = for_model("models/weapons/w_pistol.mdl", None);
|
||||
assert_eq!(pistol.angles.y, -90.0, "у w_pistol ствол вдоль +Y");
|
||||
let view_pistol = for_model("MODELS\\Weapons\\V_Pistol", None);
|
||||
assert_eq!(view_pistol.angles.y, 0.0, "у v_pistol ствол вдоль +X");
|
||||
assert_ne!(
|
||||
pistol.distance, view_pistol.distance,
|
||||
"дистанция подбирается по габариту модели"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swep_offset_and_fov_override_table() {
|
||||
let base = for_model("models/weapons/w_pistol.mdl", None);
|
||||
let tuning = ViewModelTuning {
|
||||
fov: Some(90.0),
|
||||
offset: Some(Vec3::new(50.0, -12.0, -6.0)),
|
||||
angles: Some(Vec3::new(0.0, 15.0, 7.0)),
|
||||
scale: Some(0.5),
|
||||
};
|
||||
let placement = for_model("models/weapons/w_pistol.mdl", Some(&tuning));
|
||||
assert_eq!(placement.distance, 50.0);
|
||||
assert_eq!(placement.right, 12.0);
|
||||
assert_eq!(placement.up, -6.0);
|
||||
assert_eq!(placement.angles, Vec3::new(0.0, 15.0, 7.0));
|
||||
assert!((placement.scale - 0.5).abs() < 1e-6);
|
||||
assert!(
|
||||
placement.scale < base.scale,
|
||||
"явный ViewModelScale важнее поправки FOV"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tuning_offset_is_bounded() {
|
||||
let placement = for_model(
|
||||
"models/weapons/c_smg1.mdl",
|
||||
Some(&ViewModelTuning {
|
||||
offset: Some(Vec3::new(0.1, 10_000.0, -10_000.0)),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(placement.distance, 5.0, "не ближе near-плоскости");
|
||||
assert_eq!(placement.right, -500.0);
|
||||
assert_eq!(placement.up, -500.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_tuning_values_are_ignored() {
|
||||
let base = for_model("models/weapons/c_smg1.mdl", None);
|
||||
let tuning = ViewModelTuning {
|
||||
fov: Some(f32::NAN),
|
||||
offset: Some(Vec3::new(0.0, f32::INFINITY, 0.0)),
|
||||
angles: Some(Vec3::new(f32::NAN, 0.0, 0.0)),
|
||||
scale: Some(-1.0),
|
||||
};
|
||||
assert_eq!(for_model("models/weapons/c_smg1.mdl", Some(&tuning)), base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_world_model_gets_y_sided_barrel() {
|
||||
let placement = for_model("models/weapons/w_crowbar.mdl", None);
|
||||
assert_eq!(placement.angles.y, -90.0);
|
||||
let placement = for_model("models/weapons/c_medkit.mdl", None);
|
||||
assert_eq!(placement.angles.y, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fov_multiplier_is_bounded() {
|
||||
let base = for_model("models/weapons/c_smg1.mdl", None).scale;
|
||||
let wide = for_model(
|
||||
"models/weapons/c_smg1.mdl",
|
||||
Some(&ViewModelTuning {
|
||||
fov: Some(170.0),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(
|
||||
(wide.scale - base * 0.45).abs() < 1e-6,
|
||||
"широкий FOV не делает модель меньше нижней границы"
|
||||
);
|
||||
let narrow = for_model(
|
||||
"models/weapons/c_smg1.mdl",
|
||||
Some(&ViewModelTuning {
|
||||
fov: Some(10.0),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(
|
||||
(narrow.scale - base * 1.9).abs() < 1e-6,
|
||||
"узкий FOV не раздувает модель выше верхней границы"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
//! часть: какие из них можно выбрать, как переключать и как вызывать
|
||||
//! `PrimaryAttack`/`Think`/`Deploy`, не роняя кадр ошибкой Lua.
|
||||
|
||||
use crate::viewmodel::ViewModelTuning;
|
||||
use fs::VirtualFs;
|
||||
use glua::LuaRuntime;
|
||||
use tracing::{debug, info, warn};
|
||||
|
|
@ -24,6 +25,8 @@ pub struct WeaponInfo {
|
|||
pub clip_size: i32,
|
||||
/// Задержка выстрела, если скрипт не выставил `SetNextPrimaryFire`.
|
||||
pub primary_delay: f64,
|
||||
/// Поля SWEP, перекрывающие таблицу посадки вьюмодели.
|
||||
pub view_model_tuning: ViewModelTuning,
|
||||
}
|
||||
|
||||
impl WeaponInfo {
|
||||
|
|
@ -78,6 +81,7 @@ pub fn load(lua: &LuaRuntime, vfs: &VirtualFs) -> Vec<WeaponInfo> {
|
|||
.weapon_number(&class, "Primary.Delay")
|
||||
.filter(|delay| delay.is_finite() && *delay > 0.0)
|
||||
.unwrap_or(0.15);
|
||||
let view_model_tuning = load_view_model_tuning(lua, &class);
|
||||
weapons.push(WeaponInfo {
|
||||
class,
|
||||
name,
|
||||
|
|
@ -87,12 +91,52 @@ pub fn load(lua: &LuaRuntime, vfs: &VirtualFs) -> Vec<WeaponInfo> {
|
|||
automatic,
|
||||
clip_size,
|
||||
primary_delay,
|
||||
view_model_tuning,
|
||||
});
|
||||
}
|
||||
weapons.sort_by(|a, b| (a.slot, a.slot_pos, &a.class).cmp(&(b.slot, b.slot_pos, &b.class)));
|
||||
weapons
|
||||
}
|
||||
|
||||
/// Необязательные поля SWEP для посадки вьюмодели.
|
||||
///
|
||||
/// `ViewModelFOV` — стандартное поле GMod; `ViewModelOffset` (Source-вектор:
|
||||
/// X вперёд, Y влево, Z вверх), `ViewModelAngles` (`Angle`) и
|
||||
/// `ViewModelScale` — наши расширения для скриптов, которым мало таблицы.
|
||||
/// Неполные/нечисловые поля игнорируются целиком: лучше таблица, чем
|
||||
/// полудовёрнутая модель.
|
||||
fn load_view_model_tuning(lua: &LuaRuntime, class: &str) -> ViewModelTuning {
|
||||
let number = |field: &str| {
|
||||
lua.weapon_number(class, field)
|
||||
.map(|value| value as f32)
|
||||
.filter(|value| value.is_finite())
|
||||
};
|
||||
let fov = number("ViewModelFOV").filter(|fov| *fov > 1.0 && *fov < 180.0);
|
||||
let offset = match (
|
||||
number("ViewModelOffset.x"),
|
||||
number("ViewModelOffset.y"),
|
||||
number("ViewModelOffset.z"),
|
||||
) {
|
||||
(Some(x), Some(y), Some(z)) => Some(math::Vec3::new(x, y, z)),
|
||||
_ => None,
|
||||
};
|
||||
let angles = match (
|
||||
number("ViewModelAngles.p"),
|
||||
number("ViewModelAngles.y"),
|
||||
number("ViewModelAngles.r"),
|
||||
) {
|
||||
(Some(pitch), Some(yaw), Some(roll)) => Some(math::Vec3::new(pitch, yaw, roll)),
|
||||
_ => None,
|
||||
};
|
||||
let scale = number("ViewModelScale").filter(|scale| *scale > 0.0);
|
||||
ViewModelTuning {
|
||||
fov,
|
||||
offset,
|
||||
angles,
|
||||
scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Находит оружие по классу (без учёта регистра).
|
||||
pub fn find_class(weapons: &[WeaponInfo], class: &str) -> Option<usize> {
|
||||
weapons
|
||||
|
|
@ -318,6 +362,7 @@ mod tests {
|
|||
automatic: false,
|
||||
clip_size: -1,
|
||||
primary_delay: 0.15,
|
||||
view_model_tuning: ViewModelTuning::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -348,4 +393,44 @@ mod tests {
|
|||
info.name.clear();
|
||||
assert_eq!(info.display_name(), "weapon_fists");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_pistol_fov_reaches_tuning() {
|
||||
let lua = LuaRuntime::new().expect("lua");
|
||||
assert_eq!(lua.load_builtin_weapons(), 1);
|
||||
let tuning = load_view_model_tuning(&lua, "weapon_pistol");
|
||||
assert_eq!(tuning.fov, Some(54.0));
|
||||
assert!(tuning.offset.is_none());
|
||||
assert!(tuning.angles.is_none());
|
||||
assert!(tuning.scale.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swep_placement_fields_are_read() {
|
||||
let lua = LuaRuntime::new().expect("lua");
|
||||
let dir = std::env::temp_dir().join("source_rs_swep_tuning_test");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("lua/weapons")).expect("каталог");
|
||||
std::fs::write(
|
||||
dir.join("lua/weapons/weapon_tuning_test.lua"),
|
||||
br##"
|
||||
SWEP.PrintName = "Tuning"
|
||||
SWEP.Spawnable = true
|
||||
SWEP.ViewModel = Model("models/weapons/w_pistol.mdl")
|
||||
SWEP.ViewModelFOV = 70
|
||||
SWEP.ViewModelOffset = Vector(30, -12, -7)
|
||||
SWEP.ViewModelAngles = Angle(1, 2, 3)
|
||||
SWEP.ViewModelScale = 0.6
|
||||
"##,
|
||||
)
|
||||
.expect("файл");
|
||||
let (loaded, _, failed) = lua.load_scripts(&dir);
|
||||
assert_eq!((loaded, failed), (1, 0));
|
||||
let tuning = load_view_model_tuning(&lua, "weapon_tuning_test");
|
||||
assert_eq!(tuning.fov, Some(70.0));
|
||||
assert_eq!(tuning.offset, Some(math::Vec3::new(30.0, -12.0, -7.0)));
|
||||
assert_eq!(tuning.angles, Some(math::Vec3::new(1.0, 2.0, 3.0)));
|
||||
assert_eq!(tuning.scale, Some(0.6));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2819,10 +2819,14 @@ impl World {
|
|||
mesh_index: usize,
|
||||
first: u32,
|
||||
count: u32,
|
||||
origin: Vec3,
|
||||
angles: Vec3,
|
||||
model_yaw: f32,
|
||||
transform: InstanceTransform,
|
||||
) -> bool {
|
||||
let InstanceTransform {
|
||||
origin,
|
||||
angles,
|
||||
model_angles,
|
||||
scale,
|
||||
} = transform;
|
||||
let (first, count) = (first as usize, count as usize);
|
||||
let Some(mesh) = self.prop_meshes.get(mesh_index) else {
|
||||
return false;
|
||||
|
|
@ -2837,14 +2841,24 @@ impl World {
|
|||
mesh.positions[i][0],
|
||||
mesh.positions[i][1],
|
||||
mesh.positions[i][2],
|
||||
);
|
||||
) * scale;
|
||||
// Доворот в координатах модели (ось вперёд у оружия своя), затем
|
||||
// поворот камеры. Порядок важен: доворот по мировому yaw при
|
||||
// взгляде вниз превращался в крен.
|
||||
let local = crate::model::rotate_model_vector(local_model, 0.0, model_yaw, 0.0);
|
||||
let local = crate::model::rotate_model_vector(
|
||||
local_model,
|
||||
model_angles.x,
|
||||
model_angles.y,
|
||||
model_angles.z,
|
||||
);
|
||||
let pos = crate::model::rotate_model_vector(local, pitch, yaw, roll) + origin;
|
||||
let local_n = Vec3::new(mesh.normals[i][0], mesh.normals[i][1], mesh.normals[i][2]);
|
||||
let local_n = crate::model::rotate_model_vector(local_n, 0.0, model_yaw, 0.0);
|
||||
let local_n = crate::model::rotate_model_vector(
|
||||
local_n,
|
||||
model_angles.x,
|
||||
model_angles.y,
|
||||
model_angles.z,
|
||||
);
|
||||
let normal = crate::model::rotate_model_vector(local_n, pitch, yaw, roll);
|
||||
let vertex = &mut self.vertices[first + i];
|
||||
vertex.pos = [pos.x, pos.y, pos.z];
|
||||
|
|
@ -2998,6 +3012,7 @@ impl World {
|
|||
mesh,
|
||||
origin,
|
||||
angles,
|
||||
scale: 1.0,
|
||||
first_vertex,
|
||||
vertex_count,
|
||||
});
|
||||
|
|
@ -3099,9 +3114,12 @@ impl World {
|
|||
mesh,
|
||||
first,
|
||||
count,
|
||||
Vec3::new(0.0, 0.0, HIDDEN_PROP_Z),
|
||||
angles,
|
||||
0.0,
|
||||
InstanceTransform {
|
||||
origin: Vec3::new(0.0, 0.0, HIDDEN_PROP_Z),
|
||||
angles,
|
||||
model_angles: Vec3::ZERO,
|
||||
scale: 1.0,
|
||||
},
|
||||
);
|
||||
self.update_prop_bounds();
|
||||
true
|
||||
|
|
@ -3227,6 +3245,9 @@ impl World {
|
|||
/// каналы это углы Эйлера и сдвиги с масштабом кости, к ним добавляется
|
||||
/// поза покоя. Вершина смешивается по своим весам:
|
||||
/// `Σ w · (A_i · B_i⁻¹) · p`, где `A` — поза кадра, `B` — поза покоя.
|
||||
///
|
||||
/// Обёртка над [`Self::play_model_animation_oriented`] для моделей, которым
|
||||
/// нужен только доворот по yaw (мир, модель игрока).
|
||||
pub fn play_model_animation(
|
||||
&mut self,
|
||||
index: usize,
|
||||
|
|
@ -3235,14 +3256,37 @@ impl World {
|
|||
animation: &str,
|
||||
time: f32,
|
||||
model_yaw: f32,
|
||||
) -> bool {
|
||||
self.play_model_animation_oriented(
|
||||
index,
|
||||
origin,
|
||||
angles,
|
||||
animation,
|
||||
time,
|
||||
Vec3::new(0.0, model_yaw, 0.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Как [`Self::play_model_animation`], но с полным доворотом модели
|
||||
/// (pitch/yaw/roll) в её собственных координатах. Нужен вьюмодели: у
|
||||
/// `w_pistol` ствол вдоль +Y, у `v_pistol`/`c_smg1` — вдоль +X.
|
||||
pub fn play_model_animation_oriented(
|
||||
&mut self,
|
||||
index: usize,
|
||||
origin: Vec3,
|
||||
angles: Vec3,
|
||||
animation: &str,
|
||||
time: f32,
|
||||
model_angles: Vec3,
|
||||
) -> bool {
|
||||
let Some(instance) = self.model_instances.get(index) else {
|
||||
return false;
|
||||
};
|
||||
let (mesh_index, first, count) = (
|
||||
let (mesh_index, first, count, scale) = (
|
||||
instance.mesh,
|
||||
instance.first_vertex as usize,
|
||||
instance.vertex_count as usize,
|
||||
instance.scale,
|
||||
);
|
||||
self.model_instances[index].origin = origin;
|
||||
self.model_instances[index].angles = angles;
|
||||
|
|
@ -3252,7 +3296,8 @@ impl World {
|
|||
};
|
||||
if std::env::var("SOURCE_RS_VIEW_DEBUG").is_ok() {
|
||||
tracing::info!(
|
||||
model_yaw,
|
||||
model_angles = ?model_angles,
|
||||
scale,
|
||||
angles = ?angles,
|
||||
count,
|
||||
skins = mesh.skins.len(),
|
||||
|
|
@ -3265,9 +3310,12 @@ impl World {
|
|||
mesh_index,
|
||||
first as u32,
|
||||
count as u32,
|
||||
origin,
|
||||
angles,
|
||||
model_yaw,
|
||||
InstanceTransform {
|
||||
origin,
|
||||
angles,
|
||||
model_angles,
|
||||
scale,
|
||||
},
|
||||
);
|
||||
}
|
||||
// Данные анимации — ссылкой: дальше меш понадобится снять из мира,
|
||||
|
|
@ -3282,9 +3330,12 @@ impl World {
|
|||
mesh_index,
|
||||
first as u32,
|
||||
count as u32,
|
||||
origin,
|
||||
angles,
|
||||
model_yaw,
|
||||
InstanceTransform {
|
||||
origin,
|
||||
angles,
|
||||
model_angles,
|
||||
scale,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -3356,7 +3407,12 @@ impl World {
|
|||
} else {
|
||||
blended
|
||||
};
|
||||
let local = crate::model::rotate_model_vector(local, 0.0, model_yaw, 0.0);
|
||||
let local = crate::model::rotate_model_vector(
|
||||
local * scale,
|
||||
model_angles.x,
|
||||
model_angles.y,
|
||||
model_angles.z,
|
||||
);
|
||||
let pos =
|
||||
crate::model::rotate_model_vector(local, angles.x, angles.y, angles.z) + origin;
|
||||
if let Some(vertex) = self.vertices.get_mut(first + i) {
|
||||
|
|
@ -3390,13 +3446,56 @@ impl World {
|
|||
|
||||
/// Двигает экземпляр модели без сущности.
|
||||
pub fn move_model_instance(&mut self, index: usize, origin: Vec3, angles: Vec3) -> bool {
|
||||
self.move_model_instance_oriented(index, origin, angles, Vec3::ZERO)
|
||||
}
|
||||
|
||||
/// Двигает экземпляр с доворотом модели в её координатах.
|
||||
pub fn move_model_instance_oriented(
|
||||
&mut self,
|
||||
index: usize,
|
||||
origin: Vec3,
|
||||
angles: Vec3,
|
||||
model_angles: Vec3,
|
||||
) -> bool {
|
||||
let Some(instance) = self.model_instances.get(index) else {
|
||||
return false;
|
||||
};
|
||||
let (mesh, first, count) = (instance.mesh, instance.first_vertex, instance.vertex_count);
|
||||
let (mesh, first, count, scale) = (
|
||||
instance.mesh,
|
||||
instance.first_vertex,
|
||||
instance.vertex_count,
|
||||
instance.scale,
|
||||
);
|
||||
self.model_instances[index].origin = origin;
|
||||
self.model_instances[index].angles = angles;
|
||||
self.rewrite_instance(mesh, first, count, origin, angles, 0.0)
|
||||
self.rewrite_instance(
|
||||
mesh,
|
||||
first,
|
||||
count,
|
||||
InstanceTransform {
|
||||
origin,
|
||||
angles,
|
||||
model_angles,
|
||||
scale,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Задаёт равномерный масштаб экземпляра (вьюмодель).
|
||||
///
|
||||
/// Значение приходит из таблицы посадки, а не из сети, но проверяется
|
||||
/// так же: нечисло/ноль/отрицательный масштаб — отказ, а не NaN в
|
||||
/// вершинах. Верхняя граница не даёт случайно «раздуть» модель на весь
|
||||
/// экран.
|
||||
pub fn set_model_instance_scale(&mut self, index: usize, scale: f32) -> bool {
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let Some(instance) = self.model_instances.get_mut(index) else {
|
||||
return false;
|
||||
};
|
||||
instance.scale = scale.clamp(0.05, 20.0);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn move_prop(&mut self, index: usize, origin: Vec3, angles: Vec3) -> bool {
|
||||
|
|
@ -3425,7 +3524,17 @@ impl World {
|
|||
// запоминает диапазон вершин для заливки на GPU. Раньше здесь была
|
||||
// своя копия перезаписи, и диапазоны не попадали в `moved_ranges` —
|
||||
// физика считала, а картинка стояла (жалоба пользователя).
|
||||
let rewritten = self.rewrite_instance(mesh_index, first, count, origin, angles, 0.0);
|
||||
let rewritten = self.rewrite_instance(
|
||||
mesh_index,
|
||||
first,
|
||||
count,
|
||||
InstanceTransform {
|
||||
origin,
|
||||
angles,
|
||||
model_angles: Vec3::ZERO,
|
||||
scale: 1.0,
|
||||
},
|
||||
);
|
||||
self.update_prop_bounds();
|
||||
rewritten
|
||||
}
|
||||
|
|
@ -3849,11 +3958,23 @@ pub struct ModelInstance {
|
|||
pub mesh: usize,
|
||||
pub origin: Vec3,
|
||||
pub angles: Vec3,
|
||||
/// Равномерный масштаб геометрии (вьюмодель держим дальше и мельче).
|
||||
pub scale: f32,
|
||||
/// Первый индекс вершины экземпляра в `World::vertices`.
|
||||
pub first_vertex: u32,
|
||||
pub vertex_count: u32,
|
||||
}
|
||||
|
||||
/// Поза экземпляра для перезаписи вершин: мировое положение, поворот
|
||||
/// камеры, доворот модели в её координатах и равномерный масштаб.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct InstanceTransform {
|
||||
origin: Vec3,
|
||||
angles: Vec3,
|
||||
model_angles: Vec3,
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
/// Экземпляр пропа: какая сущность какую часть геометрии занимает.
|
||||
///
|
||||
/// Позволяет физике и Lua двигать проп, не пересобирая весь мир, и связывает
|
||||
|
|
|
|||
|
|
@ -1578,6 +1578,89 @@ fn real_view_model_barrel_direction() {
|
|||
}
|
||||
}
|
||||
|
||||
/// Масштаб экземпляра меняет геометрию, а не только поле в структуре.
|
||||
///
|
||||
/// Вьюмодель держим дальше и мельче (компенсация общего FOV); проверяем, что
|
||||
/// `set_model_instance_scale` действительно уменьшает уже скинненные вершины.
|
||||
#[test]
|
||||
fn real_view_model_scale_changes_geometry() {
|
||||
let Some(root) = assets_root() else {
|
||||
eprintln!("пропуск: ассеты не найдены");
|
||||
return;
|
||||
};
|
||||
let map = root.join("garrysmod/maps/gm_construct.bsp");
|
||||
if !map.is_file() {
|
||||
eprintln!("пропуск: нет {}", map.display());
|
||||
return;
|
||||
}
|
||||
let mut vfs = mount_game(&root);
|
||||
let bsp = formats::bsp::Bsp::from_path(&map).expect("разбор карты");
|
||||
if let Some(pak) = bsp.pakfile_zip().expect("pakfile") {
|
||||
vfs.mount_pak(pak);
|
||||
}
|
||||
let mut world = engine::build_world(&bsp, &vfs).expect("сборка мира");
|
||||
let mut budget = 512 * 1024 * 1024;
|
||||
let index = world
|
||||
.spawn_model_instance(
|
||||
&vfs,
|
||||
"models/weapons/w_pistol.mdl",
|
||||
math::Vec3::ZERO,
|
||||
math::Vec3::ZERO,
|
||||
&mut budget,
|
||||
)
|
||||
.expect("вьюмодель");
|
||||
|
||||
let diagonal = |world: &engine::World| -> f32 {
|
||||
let points = instance_positions(world, index);
|
||||
let mut min = [f32::MAX; 3];
|
||||
let mut max = [f32::MIN; 3];
|
||||
for p in &points {
|
||||
for j in 0..3 {
|
||||
min[j] = min[j].min(p[j]);
|
||||
max[j] = max[j].max(p[j]);
|
||||
}
|
||||
}
|
||||
(0..3)
|
||||
.map(|j| (max[j] - min[j]).powi(2))
|
||||
.sum::<f32>()
|
||||
.sqrt()
|
||||
};
|
||||
|
||||
assert!(world.play_model_animation(
|
||||
index,
|
||||
math::Vec3::ZERO,
|
||||
math::Vec3::ZERO,
|
||||
"idle01",
|
||||
0.0,
|
||||
0.0
|
||||
));
|
||||
let full = diagonal(&world);
|
||||
assert!(full > 1.0, "модель должна иметь габарит, а не точку");
|
||||
assert!(world.set_model_instance_scale(index, 0.5));
|
||||
assert!(world.play_model_animation(
|
||||
index,
|
||||
math::Vec3::ZERO,
|
||||
math::Vec3::ZERO,
|
||||
"idle01",
|
||||
0.0,
|
||||
0.0
|
||||
));
|
||||
let half = diagonal(&world);
|
||||
let ratio = half / full;
|
||||
assert!(
|
||||
(ratio - 0.5).abs() < 0.01,
|
||||
"масштаб 0.5 должен уменьшить габарит вдвое: {full:.2} → {half:.2}"
|
||||
);
|
||||
assert!(
|
||||
!world.set_model_instance_scale(index, f32::NAN),
|
||||
"NaN отклоняется"
|
||||
);
|
||||
assert!(
|
||||
!world.set_model_instance_scale(index, 0.0),
|
||||
"нулевой масштаб отклоняется"
|
||||
);
|
||||
}
|
||||
|
||||
/// Стабильность пропов: не взлетают ли они на старте физики.
|
||||
///
|
||||
/// Критерий поведения: за 10 секунд ни один проп не должен подняться выше
|
||||
|
|
|
|||
74
crates/lua/src/builtin_weapon_pistol.lua
Normal file
74
crates/lua/src/builtin_weapon_pistol.lua
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
-- Встроенный SWEP weapon_pistol (HL2).
|
||||
--
|
||||
-- В HL2 пистолет — движковое оружие, а не Lua-скрипт, поэтому в ассетах
|
||||
-- Garry's Mod его нет в lua/weapons. Клиент регистрирует этот файл только
|
||||
-- тогда, когда оригинального скрипта нет: пользовательский weapon_pistol.lua
|
||||
-- всегда важнее.
|
||||
--
|
||||
-- Модель — w_pistol: у v_pistol в текущем загрузчике разъезжаются руки
|
||||
-- (второй меш висит отдельно от оружия). Посадка задаётся таблицей клиента
|
||||
-- (crates/client/src/viewmodel.rs); поля ViewModelOffset/Angles/Scale при
|
||||
-- желании перекрывают её.
|
||||
|
||||
SWEP.PrintName = "Pistol"
|
||||
SWEP.Author = "Source RS"
|
||||
SWEP.Purpose = "Standard issue pistol."
|
||||
SWEP.Category = "Half-Life 2"
|
||||
|
||||
SWEP.Slot = 1
|
||||
SWEP.SlotPos = 1
|
||||
SWEP.Spawnable = true
|
||||
SWEP.AdminOnly = false
|
||||
|
||||
SWEP.ViewModel = Model("models/weapons/w_pistol.mdl")
|
||||
SWEP.WorldModel = Model("models/weapons/w_pistol.mdl")
|
||||
SWEP.ViewModelFOV = 54
|
||||
SWEP.UseHands = false
|
||||
|
||||
SWEP.Primary.ClipSize = 18
|
||||
SWEP.Primary.DefaultClip = 18
|
||||
SWEP.Primary.Automatic = false
|
||||
SWEP.Primary.Ammo = "pistol"
|
||||
SWEP.Primary.Delay = 0.15
|
||||
|
||||
SWEP.Secondary.ClipSize = -1
|
||||
SWEP.Secondary.DefaultClip = -1
|
||||
SWEP.Secondary.Automatic = false
|
||||
SWEP.Secondary.Ammo = "none"
|
||||
|
||||
SWEP.DrawAmmo = true
|
||||
|
||||
function SWEP:Initialize()
|
||||
self:SetHoldType("pistol")
|
||||
end
|
||||
|
||||
function SWEP:Deploy()
|
||||
self:SetNextPrimaryFire(CurTime() + 0.2)
|
||||
return true
|
||||
end
|
||||
|
||||
function SWEP:Holster()
|
||||
return true
|
||||
end
|
||||
|
||||
function SWEP:Think()
|
||||
end
|
||||
|
||||
function SWEP:Reload()
|
||||
end
|
||||
|
||||
function SWEP:PrimaryAttack()
|
||||
if self:Clip1() <= 0 then
|
||||
self:EmitSound("Weapon_Pistol.Empty")
|
||||
self:SetNextPrimaryFire(CurTime() + 0.25)
|
||||
return
|
||||
end
|
||||
|
||||
self:SetNextPrimaryFire(CurTime() + 0.15)
|
||||
self:SetClip1(self:Clip1() - 1)
|
||||
-- Звук и эффекты в клиенте пока заглушки; анимацию fire/idle01
|
||||
-- проигрывает рендер вьюмодели по факту нажатия.
|
||||
self:EmitSound("Weapon_Pistol.Single")
|
||||
self:SendWeaponAnim(ACT_VM_PRIMARYATTACK)
|
||||
self:ShootEffects()
|
||||
end
|
||||
|
|
@ -12,7 +12,7 @@ pub mod ui;
|
|||
|
||||
pub use ui::{UiCommand, UiKind};
|
||||
|
||||
use mlua::{Function, Lua, MultiValue, RegistryKey, Table, Value, Variadic};
|
||||
use mlua::{Function, Lua, MultiValue, ObjectLike, RegistryKey, Table, Value, Variadic};
|
||||
use std::cell::RefCell;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::rc::Rc;
|
||||
|
|
@ -231,10 +231,12 @@ impl LuaRuntime {
|
|||
fn weapon_field_inner(&self, class: &str, field: &str) -> Option<Value> {
|
||||
let mut current = Value::Table(self.weapon_table(class).ok()?);
|
||||
for part in field.split('.') {
|
||||
let Value::Table(table) = current else {
|
||||
return None;
|
||||
current = match current {
|
||||
Value::Table(table) => table.get::<Value>(part).ok()?,
|
||||
// `Vector`/`Angle` — userdata с полями x/y/z и p/y/r.
|
||||
Value::UserData(userdata) => userdata.get::<Value>(part).ok()?,
|
||||
_ => return None,
|
||||
};
|
||||
current = table.get::<Value>(part).ok()?;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
|
@ -347,28 +349,20 @@ impl LuaRuntime {
|
|||
failed += 1;
|
||||
continue;
|
||||
};
|
||||
if kind == "weapon" {
|
||||
if self.load_weapon_source(name, &source) {
|
||||
weapons += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let globals = self.lua.globals();
|
||||
let Ok(table) = self.lua.create_table() else {
|
||||
failed += 1;
|
||||
continue;
|
||||
};
|
||||
let prepared = (|| -> mlua::Result<()> {
|
||||
if kind == "weapon" {
|
||||
table.set("Primary", self.lua.create_table()?)?;
|
||||
table.set("Secondary", self.lua.create_table()?)?;
|
||||
// Базовые методы (`GetOwner`, `SetNextPrimaryFire`,
|
||||
// `NetworkVar`, ...) приходят через метатаблицу: скрипт
|
||||
// видит их как методы SWEP, но не перезаписывает файл.
|
||||
if let Ok(Value::Table(meta)) = globals.get::<Value>("__sr_swep_meta") {
|
||||
table.set_metatable(Some(meta))?;
|
||||
}
|
||||
globals.set("SWEP", &table)?;
|
||||
} else {
|
||||
globals.set("ENT", &table)?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if prepared.is_err() {
|
||||
if globals.set("ENT", &table).is_err() {
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -376,8 +370,7 @@ impl LuaRuntime {
|
|||
// коде (`self:SetModel("models/...")`), а не полем `ENT.Model`.
|
||||
// Запоминаем первый литерал `models/*.mdl` — он и будет
|
||||
// представителем сущности в списке спавна.
|
||||
if kind == "entity"
|
||||
&& let Some(model) = find_model_literal(&source)
|
||||
if let Some(model) = find_model_literal(&source)
|
||||
&& let Err(err) = table.set("__sr_preview_model", model)
|
||||
{
|
||||
eprintln!("скрипт {}: модель для спавна: {err}", file.display());
|
||||
|
|
@ -389,80 +382,124 @@ impl LuaRuntime {
|
|||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
if kind == "weapon" {
|
||||
let class = match table.get::<Value>("ClassName") {
|
||||
Ok(Value::String(class)) => class.to_string_lossy().to_string(),
|
||||
_ => name.to_string(),
|
||||
// Скрипт мог зарегистрироваться сам через
|
||||
// `scripted_ents.Register`; если нет — регистрируем по имени
|
||||
// файла, иначе сущность не найти.
|
||||
let registered = (|| -> mlua::Result<()> {
|
||||
let store: Table = globals.get("__sr_ents")?;
|
||||
if !matches!(store.get::<Value>(name)?, Value::Nil) {
|
||||
return Ok(());
|
||||
}
|
||||
table.set("Type", name)?;
|
||||
store.set(name, &table)?;
|
||||
let lists: Table = globals.get("__sr_lists")?;
|
||||
let entries: Table = match lists.get::<Value>("SpawnableEntities")? {
|
||||
Value::Table(entries) => entries,
|
||||
_ => {
|
||||
let entries = self.lua.create_table()?;
|
||||
lists.set("SpawnableEntities", &entries)?;
|
||||
entries
|
||||
}
|
||||
};
|
||||
let registered = (|| -> mlua::Result<()> {
|
||||
let store: Table = globals.get("__sr_weapons")?;
|
||||
store.set(class.clone(), &table)?;
|
||||
let lists: Table = globals.get("__sr_lists")?;
|
||||
let entries: Table = match lists.get::<Value>("Weapon")? {
|
||||
Value::Table(entries) => entries,
|
||||
_ => {
|
||||
let entries = self.lua.create_table()?;
|
||||
lists.set("Weapon", &entries)?;
|
||||
entries
|
||||
}
|
||||
};
|
||||
entries.set(class.clone(), &table)?;
|
||||
Ok(())
|
||||
})();
|
||||
if registered.is_ok() {
|
||||
weapons += 1;
|
||||
// Стартовый запас: в GMod `DefaultClip` попадает в
|
||||
// оружие при выдаче. Без этого `Clip1()` у медика был
|
||||
// бы нулевым, и HUD показывал бы пустой магазин.
|
||||
if let Some(clip) = self.weapon_number(&class, "Primary.ClipSize")
|
||||
&& clip > 0.0
|
||||
{
|
||||
let _ = table.set("__clip", clip as i64);
|
||||
}
|
||||
// Порядок как в GMod: сначала сетевые переменные,
|
||||
// потом `Initialize` (он на них опирается).
|
||||
for method in ["SetupDataTables", "Initialize"] {
|
||||
if let Err(err) = self.call_weapon_method(&class, method) {
|
||||
eprintln!("оружие {class}: {method}: {err}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
entries.set(name, &table)?;
|
||||
Ok(())
|
||||
})();
|
||||
if registered.is_ok() {
|
||||
entities += 1;
|
||||
} else {
|
||||
// Скрипт мог зарегистрироваться сам через
|
||||
// `scripted_ents.Register`; если нет — регистрируем по имени
|
||||
// файла, иначе сущность не найти.
|
||||
let registered = (|| -> mlua::Result<()> {
|
||||
let store: Table = globals.get("__sr_ents")?;
|
||||
if !matches!(store.get::<Value>(name)?, Value::Nil) {
|
||||
return Ok(());
|
||||
}
|
||||
table.set("Type", name)?;
|
||||
store.set(name, &table)?;
|
||||
let lists: Table = globals.get("__sr_lists")?;
|
||||
let entries: Table = match lists.get::<Value>("SpawnableEntities")? {
|
||||
Value::Table(entries) => entries,
|
||||
_ => {
|
||||
let entries = self.lua.create_table()?;
|
||||
lists.set("SpawnableEntities", &entries)?;
|
||||
entries
|
||||
}
|
||||
};
|
||||
entries.set(name, &table)?;
|
||||
Ok(())
|
||||
})();
|
||||
if registered.is_ok() {
|
||||
entities += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(weapons, entities, failed)
|
||||
}
|
||||
|
||||
/// Загружает один SWEP-исходник в свежую таблицу `SWEP`.
|
||||
///
|
||||
/// Общий путь для `lua/weapons/*.lua` и встроенных скриптов: подготовка
|
||||
/// `Primary`/`Secondary` и метатаблицы, выполнение, регистрация в
|
||||
/// `weapons`/`list.Get("Weapon")`, стартовый магазин и `Initialize`.
|
||||
/// Возвращает `false`, если скрипт не выполнился или не зарегистрировался.
|
||||
fn load_weapon_source(&self, name: &str, source: &str) -> bool {
|
||||
let globals = self.lua.globals();
|
||||
let Ok(table) = self.lua.create_table() else {
|
||||
return false;
|
||||
};
|
||||
let prepared = (|| -> mlua::Result<()> {
|
||||
table.set("Primary", self.lua.create_table()?)?;
|
||||
table.set("Secondary", self.lua.create_table()?)?;
|
||||
// Базовые методы (`GetOwner`, `SetNextPrimaryFire`,
|
||||
// `NetworkVar`, ...) приходят через метатаблицу: скрипт
|
||||
// видит их как методы SWEP, но не перезаписывает файл.
|
||||
if let Ok(Value::Table(meta)) = globals.get::<Value>("__sr_swep_meta") {
|
||||
table.set_metatable(Some(meta))?;
|
||||
}
|
||||
globals.set("SWEP", &table)?;
|
||||
Ok(())
|
||||
})();
|
||||
if prepared.is_err() {
|
||||
return false;
|
||||
}
|
||||
if let Err(err) = self.run(name, source.as_bytes()) {
|
||||
eprintln!("скрипт {name}: {err}");
|
||||
return false;
|
||||
}
|
||||
let class = match table.get::<Value>("ClassName") {
|
||||
Ok(Value::String(class)) => class.to_string_lossy().to_string(),
|
||||
_ => name.to_string(),
|
||||
};
|
||||
let registered = (|| -> mlua::Result<()> {
|
||||
let store: Table = globals.get("__sr_weapons")?;
|
||||
store.set(class.clone(), &table)?;
|
||||
let lists: Table = globals.get("__sr_lists")?;
|
||||
let entries: Table = match lists.get::<Value>("Weapon")? {
|
||||
Value::Table(entries) => entries,
|
||||
_ => {
|
||||
let entries = self.lua.create_table()?;
|
||||
lists.set("Weapon", &entries)?;
|
||||
entries
|
||||
}
|
||||
};
|
||||
entries.set(class.clone(), &table)?;
|
||||
Ok(())
|
||||
})();
|
||||
if registered.is_err() {
|
||||
return false;
|
||||
}
|
||||
// Стартовый запас: в GMod `DefaultClip` попадает в оружие при выдаче.
|
||||
// Без этого `Clip1()` у медика был бы нулевым, и HUD показывал бы
|
||||
// пустой магазин.
|
||||
if let Some(clip) = self.weapon_number(&class, "Primary.ClipSize")
|
||||
&& clip > 0.0
|
||||
{
|
||||
let _ = table.set("__clip", clip as i64);
|
||||
}
|
||||
// Порядок как в GMod: сначала сетевые переменные, потом `Initialize`
|
||||
// (он на них опирается).
|
||||
for method in ["SetupDataTables", "Initialize"] {
|
||||
if let Err(err) = self.call_weapon_method(&class, method) {
|
||||
eprintln!("оружие {class}: {method}: {err}");
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Регистрирует встроенные SWEP, которых нет в ассетах пользователя.
|
||||
///
|
||||
/// `weapon_pistol` в HL2 — движковое оружие, а не Lua-скрипт, поэтому в
|
||||
/// GMod его нет в `lua/weapons`. Встроенная реализация уступает оригиналу:
|
||||
/// если пользователь положил свой `weapon_pistol.lua`, он уже в реестре и
|
||||
/// ничего не перезаписывается. Возвращает число добавленных оружий.
|
||||
pub fn load_builtin_weapons(&self) -> usize {
|
||||
let mut loaded = 0;
|
||||
if self.weapon_table("weapon_pistol").is_err()
|
||||
&& self.load_weapon_source("weapon_pistol", include_str!("builtin_weapon_pistol.lua"))
|
||||
{
|
||||
loaded += 1;
|
||||
}
|
||||
loaded
|
||||
}
|
||||
|
||||
/// Загружает геймод: `gamemodes/<mode>/gamemode/init.lua` и `cl_init.lua`.
|
||||
///
|
||||
/// В отличие от [`Self::load_scripts`] ошибки не прерывают загрузку: файл
|
||||
|
|
|
|||
|
|
@ -283,6 +283,90 @@ mod tests {
|
|||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_pistol_registers_once_and_shoots() {
|
||||
let runtime = runtime();
|
||||
assert_eq!(
|
||||
runtime.load_builtin_weapons(),
|
||||
1,
|
||||
"пистолет зарегистрирован"
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.load_builtin_weapons(),
|
||||
0,
|
||||
"повторная загрузка не перезаписывает оружие"
|
||||
);
|
||||
let value = |expr: &str| {
|
||||
runtime
|
||||
.probe(expr)
|
||||
.expect("probe")
|
||||
.unwrap_or_else(|| "nil".to_string())
|
||||
};
|
||||
assert_eq!(
|
||||
value("return weapons.Get('weapon_pistol').PrintName"),
|
||||
"Pistol"
|
||||
);
|
||||
assert_eq!(
|
||||
value(
|
||||
"return tostring(weapons.Get('weapon_pistol').Slot)..':'..tostring(weapons.Get('weapon_pistol').SlotPos)"
|
||||
),
|
||||
"1:1"
|
||||
);
|
||||
assert_eq!(
|
||||
value("return tostring(list.Get('Weapon')['weapon_pistol'] ~= nil)"),
|
||||
"true",
|
||||
"виден в list.Get(\"Weapon\")"
|
||||
);
|
||||
assert_eq!(
|
||||
value("return tostring(weapons.Get('weapon_pistol'):Clip1())"),
|
||||
"18",
|
||||
"стартовый магазин из ClipSize"
|
||||
);
|
||||
runtime
|
||||
.call_weapon_method("weapon_pistol", "PrimaryAttack")
|
||||
.expect("PrimaryAttack");
|
||||
assert_eq!(
|
||||
value("return tostring(weapons.Get('weapon_pistol'):Clip1())"),
|
||||
"17",
|
||||
"выстрел уменьшил магазин"
|
||||
);
|
||||
for method in ["Deploy", "Think", "Holster"] {
|
||||
runtime
|
||||
.call_weapon_method("weapon_pistol", method)
|
||||
.unwrap_or_else(|err| panic!("{method}: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_weapon_overrides_builtin() {
|
||||
let runtime = runtime();
|
||||
let dir = std::env::temp_dir().join("source_rs_builtin_pistol_test");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("lua/weapons")).expect("каталог");
|
||||
std::fs::write(
|
||||
dir.join("lua/weapons/weapon_pistol.lua"),
|
||||
br##"
|
||||
SWEP.PrintName = "User Pistol"
|
||||
SWEP.Spawnable = true
|
||||
SWEP.ViewModel = Model("models/weapons/w_pistol.mdl")
|
||||
"##,
|
||||
)
|
||||
.expect("файл");
|
||||
let (loaded, _, failed) = runtime.load_scripts(&dir);
|
||||
assert_eq!((loaded, failed), (1, 0));
|
||||
assert_eq!(
|
||||
runtime.load_builtin_weapons(),
|
||||
0,
|
||||
"оригинальный скрипт важнее встроенного"
|
||||
);
|
||||
let name = runtime
|
||||
.probe("return weapons.Get('weapon_pistol').PrintName")
|
||||
.expect("probe")
|
||||
.unwrap_or_default();
|
||||
assert_eq!(name, "User Pistol");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_and_isvalid_are_safe_without_arguments() {
|
||||
let runtime = runtime();
|
||||
|
|
|
|||
|
|
@ -8,6 +8,65 @@
|
|||
|
||||
---
|
||||
|
||||
## 0.12. Вьюмодель: посадка по модели и встроенный пистолет (2026-10-24, ветка `feat/viewmodel`)
|
||||
|
||||
**Пистолет в реестре.** `weapon_pistol` в HL2 — движковое оружие, поэтому в
|
||||
`lua/weapons` ассетов его нет, и слот 1 был занят только флечетганом. В
|
||||
`glua` добавлен встроенный SWEP (`builtin_weapon_pistol.lua`):
|
||||
`Slot=1`, `SlotPos=1`, `Spawnable`, `PrintName="Pistol"`, магазин 18,
|
||||
одиночный огонь, `PrimaryAttack` уменьшает магазин и зовёт заглушки
|
||||
`EmitSound`/`SendWeaponAnim`. Регистрация идёт через общий с файловыми
|
||||
скриптами путь `load_weapon_source`, поэтому оружие видно и в
|
||||
`list.Get("Weapon")`, и в `weapons.Get`. Если пользователь положил свой
|
||||
`lua/weapons/weapon_pistol.lua`, встроенный не загружается (правило
|
||||
«оригинал важнее»). `ViewModel` — `w_pistol.mdl`: у `v_pistol` в текущем
|
||||
загрузчике второй меш (руки) висит отдельно от оружия; посадка для него всё
|
||||
равно описана в таблице.
|
||||
|
||||
**Посадка.** Общие константы (дистанция 26, `view_yaw=-90`) подходили только
|
||||
`w_pistol`: у `v_pistol`/`c_smg1` ствол вдоль **+X**, а не +Y. В
|
||||
`client/src/viewmodel.rs` появилась таблица `model → {right, up, distance,
|
||||
scale, angles}`; значения подобраны кадрами:
|
||||
|
||||
| модель | right | up | distance | scale | yaw |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| `w_pistol` | 14 | −9 | 20 | 1.0 | −90° |
|
||||
| `v_pistol` | 8 | −10 | 34 | 0.8 | 0° |
|
||||
| `c_smg1` | 14 | −10 | 26 | 1.0 | 0° |
|
||||
|
||||
Масштаб поддержан в движке: `ModelInstance.scale`, `set_model_instance_scale`,
|
||||
`play_model_animation_oriented`/`move_model_instance_oriented` (полный
|
||||
pitch/yaw/roll в координатах модели). Отладочный лог `SOURCE_RS_VIEW_DEBUG`
|
||||
печатает габарит уже скинненной позы в осях камеры и число вершин ближе
|
||||
near-плоскости; для всех трёх моделей `ближе_near=0`.
|
||||
|
||||
**Поля SWEP.** `ViewModelFOV` пересчитывается в множитель масштаба (опорный
|
||||
54°), `ViewModelOffset` (Source-вектор: X вперёд, Y влево, Z вверх),
|
||||
`ViewModelAngles` и `ViewModelScale` перекрывают таблицу. Для этого
|
||||
`weapon_field` научился читать поля `Vector`/`Angle` (userdata), а не только
|
||||
таблицы.
|
||||
|
||||
**Кулаки.** `c_arms.mdl` по-прежнему без геометрии: `spawn_model_instance`
|
||||
возвращает ошибку, вьюмодель скрывается. Переход «кулаки → пистолет»
|
||||
пересоздаёт экземпляр (проверено кадром).
|
||||
|
||||
**Проверка (Xvfb :99, `--hidden`, 1280×720).** Кадры:
|
||||
`/tmp/opencode/vm2/final_pistol.png`, `final_flech.png`,
|
||||
`final_pistol_attack.png` (17/18 после выстрела), `switch_flech_to_pistol.png`,
|
||||
`switch_fists_to_pistol.png`, `switch_pistol_to_fists.png`,
|
||||
`vpistol_d34.png`. На Xvfb `xdotool` не доставляет клавиши в SDL-окно,
|
||||
поэтому для проверки слота добавлен `SOURCE_RS_TEST_SLOT` — синтетическое
|
||||
`KeyDown` того же кода, что и настоящая клавиша (на кадре
|
||||
`SOURCE_RS_TEST_SLOT_FRAME`, по умолчанию 30). Юнит-тест
|
||||
`slot_keys_match_gmod` проверяет обратное соответствие клавиш 0–9.
|
||||
|
||||
**Осталось:** отдельный проход вьюмодели с настоящим `ViewModelFOV` (сейчас
|
||||
компенсация масштабом — приближение); разобраться с оторванными руками
|
||||
`v_pistol` (после этого можно вернуть его во встроенный пистолет); тёмная
|
||||
текстура `c_smg1` (ориентация и посадка уже верные).
|
||||
|
||||
---
|
||||
|
||||
## 0.11. Модели: локальные индексы вершин и пол под пропами (2026-10-24, ветка `feat/models`)
|
||||
|
||||
- Причина «взрывов» мешей: `origMeshVertID` в VTX — локальный индекс внутри
|
||||
|
|
|
|||
|
|
@ -3141,6 +3141,16 @@ Sandbox.
|
|||
вьюмодели, проигрывание `SendViewModelMatchingSequence`, урон/эффекты, `DrawHUD`
|
||||
скриптов.
|
||||
|
||||
**Сделано (ветка `feat/viewmodel`):** встроенный SWEP `weapon_pistol` (Slot 1,
|
||||
SlotPos 1, 18 патронов, одиночный огонь) — в `list.Get("Weapon")` и слотах,
|
||||
пользовательский `lua/weapons/weapon_pistol.lua` важнее встроенного. Посадка
|
||||
вьюмодели стала таблицей по модели (`w_pistol`: yaw −90°, 20 юнитов;
|
||||
`v_pistol`: yaw 0°, 34, scale 0.8; `c_smg1`: yaw 0°, 26) с поддержкой масштаба
|
||||
в движке; `ViewModelFOV` и необязательные `ViewModelOffset`/`Angles`/`Scale`
|
||||
из SWEP перекрывают таблицу. Отдельного прохода с FOV вьюмодели пока нет —
|
||||
компенсация масштабом; у `v_pistol` остаются оторванные руки, поэтому
|
||||
встроенный пистолет использует `w_pistol`.
|
||||
|
||||
**Сделано (ветка `feat/sandbox`):** физган как отдельный режим `F` (ЛКМ —
|
||||
захват и плавное удержание скоростью, колесо — доворот, ПКМ — freeze),
|
||||
дальность 250 и предел массы 500, луч/крест в отладочном слое, жёлтый маркер
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue