Вьюмодель: посадка по модели и встроенный 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:
Niko Marmeladkov 2026-10-24 21:40:00 +03:00
parent 21abe0d4b7
commit 32aad74eb5
11 changed files with 1114 additions and 152 deletions

View file

@ -13,6 +13,7 @@ mod qmenu;
mod sandbox;
mod touch;
mod ui_overlay;
mod viewmodel;
mod weapon;
use clap::{Parser, Subcommand};

View file

@ -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);
}
}

View 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 не раздувает модель выше верхней границы"
);
}
}

View file

@ -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);
}
}

View file

@ -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 двигать проп, не пересобирая весь мир, и связывает

View file

@ -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 секунд ни один проп не должен подняться выше

View 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

View file

@ -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`] ошибки не прерывают загрузку: файл

View file

@ -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();

View file

@ -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 — локальный индекс внутри

View file

@ -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, луч/крест в отладочном слое, жёлтый маркер