From 32aad74eb5a2e204fb13ca630f6097e72142ad7b Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Sat, 24 Oct 2026 21:40:00 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=8C=D1=8E=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C:=20=D0=BF=D0=BE=D1=81=D0=B0=D0=B4=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=B2=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?weapon=5Fpistol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Посадка вьюмодели вынесена в таблицу по модели: у 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). --- crates/client/src/lib.rs | 1 + crates/client/src/map.rs | 207 ++++++++++++---- crates/client/src/viewmodel.rs | 285 +++++++++++++++++++++++ crates/client/src/weapon.rs | 85 +++++++ crates/engine/src/world.rs | 163 +++++++++++-- crates/engine/tests/world_real.rs | 83 +++++++ crates/lua/src/builtin_weapon_pistol.lua | 74 ++++++ crates/lua/src/lib.rs | 215 ++++++++++------- crates/lua/src/swep.rs | 84 +++++++ docs/DIARY.md | 59 +++++ docs/PARITY.md | 10 + 11 files changed, 1114 insertions(+), 152 deletions(-) create mode 100644 crates/client/src/viewmodel.rs create mode 100644 crates/lua/src/builtin_weapon_pistol.lua diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 79db065..b715cfc 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -13,6 +13,7 @@ mod qmenu; mod sandbox; mod touch; mod ui_overlay; +mod viewmodel; mod weapon; use clap::{Parser, Subcommand}; diff --git a/crates/client/src/map.rs b/crates/client/src/map.rs index 945216a..290ec1e 100644 --- a/crates/client/src/map.rs +++ b/crates/client/src/map.rs @@ -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, center: math::Vec3, + /// Посадка под конкретную модель: дистанция, масштаб, доворот. + placement: crate::viewmodel::Placement, idle: Option, fire: Option, 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 { } } +/// Обратное преобразование для тестового хука `SOURCE_RS_TEST_SLOT`. +fn weapon_keycode_for_slot(slot: i32) -> Option { + 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>, @@ -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, 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, 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, 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, args: MapArgs) -> anyhow::Result<()> { // не должны аллоцировать Vec каждый кадр. let mut debug_extra: Vec = Vec::new(); let mut frozen_markers: Vec = 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::().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 = 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, 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, 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, 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, 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, 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, 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, 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, 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, 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); + } +} diff --git a/crates/client/src/viewmodel.rs b/crates/client/src/viewmodel.rs new file mode 100644 index 0000000..aaed4f3 --- /dev/null +++ b/crates/client/src/viewmodel.rs @@ -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, + /// `SWEP.ViewModelOffset` в координатах Source: X вперёд, Y влево, Z вверх. + pub offset: Option, + /// `SWEP.ViewModelAngles`: pitch, yaw, roll. + pub angles: Option, + /// `SWEP.ViewModelScale`. + pub scale: Option, +} + +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 { + let value = std::env::var(name).ok()?; + let value = value.trim().parse::().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 не раздувает модель выше верхней границы" + ); + } +} diff --git a/crates/client/src/weapon.rs b/crates/client/src/weapon.rs index fb00b4d..467e5f4 100644 --- a/crates/client/src/weapon.rs +++ b/crates/client/src/weapon.rs @@ -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 { .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 { 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 { 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); + } } diff --git a/crates/engine/src/world.rs b/crates/engine/src/world.rs index a45cd7c..54242b0 100644 --- a/crates/engine/src/world.rs +++ b/crates/engine/src/world.rs @@ -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 двигать проп, не пересобирая весь мир, и связывает diff --git a/crates/engine/tests/world_real.rs b/crates/engine/tests/world_real.rs index bc8d0a2..f784724 100644 --- a/crates/engine/tests/world_real.rs +++ b/crates/engine/tests/world_real.rs @@ -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::() + .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 секунд ни один проп не должен подняться выше diff --git a/crates/lua/src/builtin_weapon_pistol.lua b/crates/lua/src/builtin_weapon_pistol.lua new file mode 100644 index 0000000..75f5570 --- /dev/null +++ b/crates/lua/src/builtin_weapon_pistol.lua @@ -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 diff --git a/crates/lua/src/lib.rs b/crates/lua/src/lib.rs index 5d44f8f..f602194 100644 --- a/crates/lua/src/lib.rs +++ b/crates/lua/src/lib.rs @@ -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 { 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::(part).ok()?, + // `Vector`/`Angle` — userdata с полями x/y/z и p/y/r. + Value::UserData(userdata) => userdata.get::(part).ok()?, + _ => return None, }; - current = table.get::(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::("__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::("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::(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::("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::("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::(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::("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::("__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::("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::("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//gamemode/init.lua` и `cl_init.lua`. /// /// В отличие от [`Self::load_scripts`] ошибки не прерывают загрузку: файл diff --git a/crates/lua/src/swep.rs b/crates/lua/src/swep.rs index 38b0830..bc299a2 100644 --- a/crates/lua/src/swep.rs +++ b/crates/lua/src/swep.rs @@ -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(); diff --git a/docs/DIARY.md b/docs/DIARY.md index 5a7fdf0..4bd76e3 100644 --- a/docs/DIARY.md +++ b/docs/DIARY.md @@ -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 — локальный индекс внутри diff --git a/docs/PARITY.md b/docs/PARITY.md index cd9f14b..b5af6aa 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -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, луч/крест в отладочном слое, жёлтый маркер