diff --git a/crates/client/src/map.rs b/crates/client/src/map.rs index 7d53571..139d3b8 100644 --- a/crates/client/src/map.rs +++ b/crates/client/src/map.rs @@ -498,6 +498,39 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { // проверять физган кадром и скриптом, а не руками. let auto_grab = std::env::var_os("SOURCE_RS_PHYSGUN").is_some(); + // Анимация вьюмодели (A26): покой и выстрел. Имена у моделей разные + // (`v_pistol` — `idle01`/`fire`, `w_pistol` — `idle`/`attack_player`), + // поэтому берём первую существующую. + let (view_idle_animation, view_fire_animation, view_fire_duration) = + if let Some(index) = view_model_instance { + let w = world.borrow(); + let idle = ["idle01", "idle"] + .into_iter() + .find(|name| w.model_has_animation(index, name)); + let fire = ["fire", "fire1", "attack_player"] + .into_iter() + .find(|name| w.model_has_animation(index, name)); + let duration = fire + .and_then(|name| w.model_animation_duration(index, name)) + .unwrap_or(0.4); + (idle, fire, duration) + } else { + (None, None, 0.4) + }; + // Отсчёт одноразовой анимации выстрела: сбрасывается по её длительности. + let mut view_fire_time: Option = None; + + // Покачивание вьюмодели при ходьбе и инерционный сдвиг при повороте + // (лёгкий аналог `cl_bob`/viewmodel sway из Source). + let mut view_bob_phase = 0.0f32; + let mut view_sway_right = 0.0f32; + let mut view_sway_up = 0.0f32; + let mut prev_view_yaw = camera.yaw; + let mut prev_view_pitch = camera.pitch; + + // Анимация модели игрока по состоянию (A26): тип и фаза. + let mut player_animation = engine::AnimationState::default(); + 'running: loop { for event in events.poll_iter() { match event { @@ -539,6 +572,14 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { while log.len() > 8 { log.pop_front(); } + } else if view_fire_animation.is_some() { + // Под прицелом нет тела — ЛКМ стреляет: одноразовая + // анимация вьюмодели, без оружия и урона (A23). + view_fire_time = Some(0.0); + log.push_back("выстрел".to_string()); + while log.len() > 8 { + log.pop_front(); + } } } Event::MouseButtonUp { @@ -664,6 +705,12 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { let dt = last.elapsed().as_secs_f32().min(0.1); last = Instant::now(); + // Одноразовая анимация выстрела: тикает и гаснет по длительности. + if let Some(time) = view_fire_time { + let time = time + dt; + view_fire_time = (time < view_fire_duration).then_some(time); + } + // Физика идёт до ходьбы игрока: игрок должен упираться в уже // сдвинутые пропы, а не в прошлые. wave_time += dt; @@ -788,6 +835,52 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { camera.pos = camera.pos - forward * 128.0 + math::Vec3::new(0.0, 0.0, 32.0); } + // Покачивание вьюмодели: амплитуда от фактической скорости, фаза — от + // времени ходьбы. Инерция при повороте: ствол отстаёт от камеры и + // плавно догоняет (как viewmodel sway в Source). + let horizontal_speed = + (player.velocity.x * player.velocity.x + player.velocity.y * player.velocity.y).sqrt(); + let bob_amplitude = if player.on_ground && !player.noclip { + (horizontal_speed / engine::player::RUN_SPEED).clamp(0.0, 1.0) + } else { + 0.0 + }; + view_bob_phase = + (view_bob_phase + dt * (6.0 + 6.0 * bob_amplitude)).rem_euclid(std::f32::consts::TAU); + let bob_right = view_bob_phase.sin() * 1.8 * bob_amplitude; + let bob_up = (view_bob_phase * 2.0).sin() * 1.2 * bob_amplitude; + + let yaw_delta = (camera.yaw - prev_view_yaw + 540.0).rem_euclid(360.0) - 180.0; + let pitch_delta = camera.pitch - prev_view_pitch; + prev_view_yaw = camera.yaw; + prev_view_pitch = camera.pitch; + let sway_blend = 1.0 - (-dt * 12.0).exp(); + view_sway_right += (yaw_delta * 0.03 - view_sway_right) * sway_blend; + view_sway_up += (pitch_delta * 0.03 - view_sway_up) * sway_blend; + view_sway_right = view_sway_right.clamp(-4.0, 4.0); + view_sway_up = view_sway_up.clamp(-3.0, 3.0); + + // Состояние анимации модели игрока: стоя/шаг/бег/присед/воздух и + // направление относительно взгляда. Фаза живёт в `player_animation`. + let player_pose = player_model_instance.and_then(|index| { + let w = world.borrow(); + player_animation.update( + dt, + engine::AnimationContext { + velocity: if player.noclip { + math::Vec3::ZERO + } else { + player.velocity + }, + on_ground: player.noclip || player.on_ground, + ducked: player.ducked, + sprint: player.sprint, + yaw: camera.yaw, + }, + |name| w.model_has_animation(index, name), + ) + }); + // Модели — после движения и камеры: вьюмодель обязана стоять на глазах // этого кадра, иначе она отстаёт при ходьбе и «плывёт» при повороте // (жалоба пользователя). Раньше это шло до `player.update`. @@ -807,10 +900,30 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { angles.y, angles.z, ); - let origin = eye + forward * view_distance + right * 5.0 - - math::Vec3::new(0.0, 0.0, 8.0) - - centre; - w.play_model_animation(index, origin, angles, "idle01", wave_time); + let lateral = 5.0 + bob_right + view_sway_right; + let vertical = -8.0 + bob_up + view_sway_up; + let target = eye + + forward * view_distance + + right * lateral + + math::Vec3::new(0.0, 0.0, vertical); + let mut origin = target - centre; + // Выстрел — одноразовая анимация; иначе покой. + let (animation, time) = match (view_fire_animation, view_fire_time) { + (Some(name), Some(time)) => (name, time), + _ => (view_idle_animation.unwrap_or("idle01"), wave_time), + }; + w.play_model_animation(index, origin, angles, animation, time); + // Анимация с RAWPOS корня сдвигает весь меш (у `v_pistol` — + // вниз на 68 юнитов): возвращаем центр позы в точку прицела, + // иначе ствол уезжает из кадра. Для моделей без такого сдвига + // поправка нулевая и второй проход не делается. + if let Some(posed_centre) = w.model_instance_center(index) { + let correction = target - posed_centre; + if correction.length_squared() > 1.0 { + origin += correction; + w.play_model_animation(index, origin, angles, animation, time); + } + } } if let Some(index) = player_model_instance { let inside = camera.pos.distance(player.eye_position()) < 48.0; @@ -819,13 +932,17 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } else { player.position }; - w.play_model_animation( - index, - origin, - math::Vec3::new(0.0, camera.yaw + 180.0, 0.0), - "idle_all_01", - wave_time, - ); + let angles = math::Vec3::new(0.0, camera.yaw + 180.0, 0.0); + match &player_pose { + Some(pose) => { + w.play_model_animation(index, origin, angles, &pose.name, pose.time); + } + // Анимации нет — экземпляр всё равно обязан стоять на месте + // игрока, иначе после движения останется в старой точке. + None => { + w.move_model_instance(index, origin, angles); + } + } } moved_ranges = w.take_moved_ranges(); moved_index_ranges = w.take_moved_index_ranges(); diff --git a/crates/engine/src/animation.rs b/crates/engine/src/animation.rs new file mode 100644 index 0000000..3e6bd1c --- /dev/null +++ b/crates/engine/src/animation.rs @@ -0,0 +1,433 @@ +//! Выбор анимации модели игрока по состоянию (A26). +//! +//! Состояние берётся из `Player` (скорость, земля/воздух, присед, бег), а +//! направление — из горизонтальной скорости относительно взгляда. Секвенции +//! GMod (`m_anm.mdl`) размечены по восьми направлениям: `a_WalkN`, `a_WalkNE`, +//! …, `a_RunN`, `a_CwalkN`. Клиент только проигрывает выбранное имя, поэтому +//! сам выбор — чистая функция и проверяется без ассетов. + +use math::Vec3; + +/// Ниже этой горизонтальной скорости игрок считается стоящим. +pub const MOVING_SPEED: f32 = 5.0; + +/// Порог «это уже бег» для выбора `a_Run*` без флага `sprint`. +/// +/// 250 — шаг, 520 — бег; середина разделяет их с запасом на разгон. +pub const RUN_ANIM_SPEED: f32 = 320.0; + +/// Стоячая анимация (имя в библиотеке GMod — `@idle_all_01`). +pub const IDLE_ANIMATION: &str = "idle_all_01"; +/// Стоячая анимация в приседе. +pub const CROUCH_IDLE_ANIMATION: &str = "@cidle_all"; + +/// Анимации прыжка: первая существующая в модели. У GMod они per-weapon, а +/// оружия в руках пока нет — начинаем с кулаков, затем пистолет, затем общая +/// дельта прыжка. +pub const AIR_ANIMATIONS: [&str; 3] = ["@jump_fist", "@jump_pistol", "a_gmod_jump_delta"]; + +/// Направление движения в системе модели (N — вперёд по взгляду). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MoveDirection { + #[default] + N, + NE, + E, + SE, + S, + SW, + W, + NW, +} + +impl MoveDirection { + /// Все направления по часовой стрелке от «вперёд». + pub const ALL: [MoveDirection; 8] = [ + MoveDirection::N, + MoveDirection::NE, + MoveDirection::E, + MoveDirection::SE, + MoveDirection::S, + MoveDirection::SW, + MoveDirection::W, + MoveDirection::NW, + ]; + + /// Суффикс анимации GMod (`a_WalkN`, `a_WalkNE`, …). + pub fn suffix(self) -> &'static str { + match self { + MoveDirection::N => "N", + MoveDirection::NE => "NE", + MoveDirection::E => "E", + MoveDirection::SE => "SE", + MoveDirection::S => "S", + MoveDirection::SW => "SW", + MoveDirection::W => "W", + MoveDirection::NW => "NW", + } + } + + /// Направление горизонтальной скорости относительно взгляда (`yaw`, как в + /// Source: 0 — +X, +Y — влево). + /// + /// Берём угол между вектором скорости и «вперёд» и округляем до 45°: + /// сектор 0 — вперёд (N), +1 — влево-вперёд (NW), −1 — вправо-вперёд (NE). + /// Так стрейф вправо даёт `E`, влево — `W`. + pub fn from_velocity(velocity: Vec3, yaw: f32) -> Self { + let (x, y) = (velocity.x, velocity.y); + if x * x + y * y < 1e-6 { + return MoveDirection::N; + } + let relative = y.atan2(x) - yaw.to_radians(); + let sector = (relative / std::f32::consts::FRAC_PI_4).round() as i32; + // Целые сектора: 0 N, 1 NW, 2 W, 3 SW, 4 S, 5 SE, 6 E, 7 NE. + match sector.rem_euclid(8) { + 0 => MoveDirection::N, + 1 => MoveDirection::NW, + 2 => MoveDirection::W, + 3 => MoveDirection::SW, + 4 => MoveDirection::S, + 5 => MoveDirection::SE, + 6 => MoveDirection::E, + _ => MoveDirection::NE, + } + } +} + +/// Тип движения — определяет и имя секвенции, и момент сброса фазы. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AnimationKind { + Idle, + Walk, + Run, + CrouchIdle, + CrouchWalk, + Air, +} + +impl AnimationKind { + /// Группа фазы: walk ↔ run продолжают анимацию с того же места (у них + /// совпадает цикл шага), остальные типы начинают с нуля. + fn phase_group(self) -> u8 { + match self { + AnimationKind::Idle => 0, + AnimationKind::Walk | AnimationKind::Run => 1, + AnimationKind::CrouchIdle => 2, + AnimationKind::CrouchWalk => 3, + AnimationKind::Air => 4, + } + } + + /// Имя анимации по типу и направлению. + pub fn animation_name(self, direction: MoveDirection) -> String { + match self { + AnimationKind::Idle => IDLE_ANIMATION.to_string(), + AnimationKind::CrouchIdle => CROUCH_IDLE_ANIMATION.to_string(), + AnimationKind::Walk => format!("a_Walk{}", direction.suffix()), + AnimationKind::Run => format!("a_Run{}", direction.suffix()), + AnimationKind::CrouchWalk => format!("a_Cwalk{}", direction.suffix()), + AnimationKind::Air => AIR_ANIMATIONS[0].to_string(), + } + } +} + +/// Всё, что нужно для выбора анимации из состояния игрока. +#[derive(Debug, Clone, Copy)] +pub struct AnimationContext { + pub velocity: Vec3, + pub on_ground: bool, + pub ducked: bool, + pub sprint: bool, + /// Курс взгляда в градусах (Source: 0 — +X, +Y — влево). + pub yaw: f32, +} + +/// Результат выбора: что играть и в какую сторону. +#[derive(Debug, Clone, PartialEq)] +pub struct AnimationChoice { + pub kind: AnimationKind, + pub direction: MoveDirection, + pub name: String, +} + +/// Выбирает анимацию по состоянию игрока. +pub fn select_animation(context: AnimationContext) -> AnimationChoice { + let speed = + (context.velocity.x * context.velocity.x + context.velocity.y * context.velocity.y).sqrt(); + let moving = speed > MOVING_SPEED; + let direction = MoveDirection::from_velocity(context.velocity, context.yaw); + let kind = if !context.on_ground { + AnimationKind::Air + } else if context.ducked { + if moving { + AnimationKind::CrouchWalk + } else { + AnimationKind::CrouchIdle + } + } else if moving { + if context.sprint || speed >= RUN_ANIM_SPEED { + AnimationKind::Run + } else { + AnimationKind::Walk + } + } else { + AnimationKind::Idle + }; + AnimationChoice { + kind, + direction, + name: kind.animation_name(direction), + } +} + +/// Что проигрывать в этом кадре и с каким временем. +#[derive(Debug, Clone, PartialEq)] +pub struct AnimationPose { + pub name: String, + pub time: f32, +} + +/// Состояние проигрывания: выбор по кадру и сохранение фазы. +#[derive(Debug, Clone, Default)] +pub struct AnimationState { + kind: Option, + name: String, + time: f32, +} + +impl AnimationState { + /// Обновляет выбор на `dt` секунд. + /// + /// `available` сообщает, есть ли анимация в модели: если выбранной нет, + /// остаётся текущая (для прыжка перебираются `AIR_ANIMATIONS`). Возвращает + /// `None`, только когда играть нечего вовсе. + pub fn update( + &mut self, + dt: f32, + context: AnimationContext, + available: F, + ) -> Option + where + F: Fn(&str) -> bool, + { + let dt = dt.max(0.0); + let choice = select_animation(context); + let mut name = choice.name; + + if choice.kind == AnimationKind::Air { + match AIR_ANIMATIONS.iter().find(|candidate| available(candidate)) { + Some(candidate) => name = (*candidate).to_string(), + None => { + // Прыжковой анимации нет — оставляем текущую, фазу не рвём. + if self.name.is_empty() { + return None; + } + self.time += dt; + return Some(AnimationPose { + name: self.name.clone(), + time: self.time, + }); + } + } + } else if !available(&name) { + if self.name.is_empty() { + return None; + } + self.time += dt; + return Some(AnimationPose { + name: self.name.clone(), + time: self.time, + }); + } + + let reset = self + .kind + .is_none_or(|kind| kind.phase_group() != choice.kind.phase_group()); + if reset { + self.time = 0.0; + } + self.time += dt; + self.kind = Some(choice.kind); + self.name = name.clone(); + Some(AnimationPose { + name, + time: self.time, + }) + } + + /// Текущее имя анимации (пусто, пока ничего не выбрано). + pub fn name(&self) -> &str { + &self.name + } + + /// Текущая фаза в секундах. + pub fn time(&self) -> f32 { + self.time + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn context( + velocity: Vec3, + on_ground: bool, + ducked: bool, + sprint: bool, + yaw: f32, + ) -> AnimationContext { + AnimationContext { + velocity, + on_ground, + ducked, + sprint, + yaw, + } + } + + fn choice( + velocity: Vec3, + on_ground: bool, + ducked: bool, + sprint: bool, + yaw: f32, + ) -> AnimationChoice { + select_animation(context(velocity, on_ground, ducked, sprint, yaw)) + } + + #[test] + fn standing_is_idle() { + let picked = choice(Vec3::ZERO, true, false, false, 0.0); + assert_eq!(picked.kind, AnimationKind::Idle); + assert_eq!(picked.name, IDLE_ANIMATION); + } + + #[test] + fn walking_and_running_forward() { + let walk = choice(Vec3::new(200.0, 0.0, 0.0), true, false, false, 0.0); + assert_eq!(walk.kind, AnimationKind::Walk); + assert_eq!(walk.name, "a_WalkN"); + + let run = choice(Vec3::new(400.0, 0.0, 0.0), true, false, false, 0.0); + assert_eq!(run.kind, AnimationKind::Run); + assert_eq!(run.name, "a_RunN"); + + // Флаг бега важнее фактической скорости (разгон ещё не закончен). + let sprint = choice(Vec3::new(260.0, 0.0, 0.0), true, false, true, 0.0); + assert_eq!(sprint.name, "a_RunN"); + } + + #[test] + fn directions_are_relative_to_view() { + // Смотрим на +X: вперёд N, влево W, вправо E, назад S. + assert_eq!( + MoveDirection::from_velocity(Vec3::new(100.0, 0.0, 0.0), 0.0), + MoveDirection::N + ); + assert_eq!( + MoveDirection::from_velocity(Vec3::new(0.0, 100.0, 0.0), 0.0), + MoveDirection::W + ); + assert_eq!( + MoveDirection::from_velocity(Vec3::new(0.0, -100.0, 0.0), 0.0), + MoveDirection::E + ); + assert_eq!( + MoveDirection::from_velocity(Vec3::new(-100.0, 0.0, 0.0), 0.0), + MoveDirection::S + ); + assert_eq!( + MoveDirection::from_velocity(Vec3::new(100.0, -100.0, 0.0), 0.0), + MoveDirection::NE + ); + assert_eq!( + MoveDirection::from_velocity(Vec3::new(100.0, 100.0, 0.0), 0.0), + MoveDirection::NW + ); + assert_eq!( + MoveDirection::from_velocity(Vec3::new(-100.0, -100.0, 0.0), 0.0), + MoveDirection::SE + ); + + // При курсе 90° вперёд — это +Y, а не +X. + assert_eq!( + MoveDirection::from_velocity(Vec3::new(0.0, 100.0, 0.0), 90.0), + MoveDirection::N + ); + // Стрейф вправо при курсе 90° — +X. + assert_eq!( + MoveDirection::from_velocity(Vec3::new(100.0, 0.0, 0.0), 90.0), + MoveDirection::E + ); + } + + #[test] + fn crouch_and_air() { + let crouch = choice(Vec3::ZERO, true, true, false, 0.0); + assert_eq!(crouch.kind, AnimationKind::CrouchIdle); + assert_eq!(crouch.name, CROUCH_IDLE_ANIMATION); + + let crouch_walk = choice(Vec3::new(0.0, -150.0, 0.0), true, true, false, 0.0); + assert_eq!(crouch_walk.kind, AnimationKind::CrouchWalk); + assert_eq!(crouch_walk.name, "a_CwalkE"); + + let air = choice(Vec3::new(100.0, 0.0, 100.0), false, false, false, 0.0); + assert_eq!(air.kind, AnimationKind::Air); + assert_eq!(air.name, AIR_ANIMATIONS[0]); + } + + #[test] + fn walk_run_keep_phase_and_types_reset() { + let mut state = AnimationState::default(); + let walk = context(Vec3::new(200.0, 0.0, 0.0), true, false, false, 0.0); + let run = context(Vec3::new(400.0, 0.0, 0.0), true, false, false, 0.0); + let idle = context(Vec3::ZERO, true, false, false, 0.0); + let available = |_: &str| true; + + let first = state.update(0.2, walk, available).expect("шаг"); + assert_eq!(first.name, "a_WalkN"); + assert!((first.time - 0.2).abs() < 1e-5); + + // walk → run: фаза продолжается, а не начинается заново. + let second = state.update(0.1, run, available).expect("бег"); + assert_eq!(second.name, "a_RunN"); + assert!((second.time - 0.3).abs() < 1e-5); + + // Смена направления внутри бега фазу тоже не сбрасывает. + let strafe = context(Vec3::new(0.0, -400.0, 0.0), true, false, false, 0.0); + let third = state.update(0.1, strafe, available).expect("стрейф"); + assert_eq!(third.name, "a_RunE"); + assert!((third.time - 0.4).abs() < 1e-5); + + // Другой тип (стоя) — сброс. + let fourth = state.update(0.1, idle, available).expect("стоим"); + assert!((fourth.time - 0.1).abs() < 1e-5); + } + + #[test] + fn missing_air_animation_keeps_current() { + let mut state = AnimationState::default(); + let walk = context(Vec3::new(200.0, 0.0, 0.0), true, false, false, 0.0); + let air = context(Vec3::new(200.0, 0.0, 100.0), false, false, false, 0.0); + let idle = context(Vec3::ZERO, true, false, false, 0.0); + + state.update(0.2, walk, |_| true).expect("шаг"); + // Прыжковых анимаций нет: остаётся последняя, время продолжается. + let pose = state + .update(0.1, air, |name| name == "a_WalkN") + .expect("остаёмся на ходьбе"); + assert_eq!(pose.name, "a_WalkN"); + assert!((pose.time - 0.3).abs() < 1e-5); + + // А если есть вторая из списка — берём её. + let pose = state + .update(0.1, air, |name| name == "@jump_pistol" || name == "a_WalkN") + .expect("прыжок"); + assert_eq!(pose.name, "@jump_pistol"); + + // Нечего играть вовсе. + let mut empty = AnimationState::default(); + assert!(empty.update(0.1, idle, |_| false).is_none()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fe28b4c..ae05329 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1,6 +1,7 @@ //! `engine` — клиентская логика мира: сборка геометрии из BSP, камера, //! система сущностей и игрок. +pub mod animation; pub mod camera; pub mod constraints; pub mod entity; @@ -9,6 +10,10 @@ pub mod physics; pub mod player; pub mod world; +pub use animation::{ + AnimationChoice, AnimationContext, AnimationKind, AnimationPose, AnimationState, MoveDirection, + select_animation, +}; pub use camera::FlyCamera; pub use entity::{Entity, EntityFlags, EntityIndex, EntityWorld, MoveType}; pub use model::{Model, ModelVertex, load_model}; diff --git a/crates/engine/src/world.rs b/crates/engine/src/world.rs index ddaa0b6..7c1c511 100644 --- a/crates/engine/src/world.rs +++ b/crates/engine/src/world.rs @@ -2452,6 +2452,30 @@ impl World { Some(self.props.len() - 1) } + /// Есть ли у экземпляра анимация с таким именем (своя или включённой + /// модели). Нужно клиенту, чтобы выбрать существующую секвенцию состояния + /// и не сбрасывать модель в позу покоя из-за опечатки в имени. + pub fn model_has_animation(&self, index: usize, animation: &str) -> bool { + let Some(instance) = self.model_instances.get(index) else { + return false; + }; + self.prop_meshes + .get(instance.mesh) + .is_some_and(|mesh| mesh.anim.iter().any(|data| data.find(animation).is_some())) + } + + /// Длительность анимации в секундах (для одноразовых проигрываний). + pub fn model_animation_duration(&self, index: usize, animation: &str) -> Option { + let instance = self.model_instances.get(index)?; + let mesh = self.prop_meshes.get(instance.mesh)?; + let anim = mesh + .anim + .iter() + .find_map(|data| data.find(animation).map(|anim| (anim, data)))?; + let fps = if anim.0.fps > 0.0 { anim.0.fps } else { 30.0 }; + Some(anim.0.num_frames.max(1) as f32 / fps) + } + /// Двигает экземпляр и переписывает вершины по кадрам анимации (A26). /// /// `animation` — имя анимации (у моделей GMod библиотека анимаций лежит во @@ -2576,6 +2600,25 @@ impl World { true } + /// Центр текущих (уже скиненных) вершин экземпляра в мировых координатах. + /// + /// Нужен вьюмодели: анимация с `RAWPOS` корня сдвигает весь меш, и + /// привязка к центру габарита должна учитывать позу, а не позу покоя. + pub fn model_instance_center(&self, index: usize) -> Option { + let instance = self.model_instances.get(index)?; + let first = instance.first_vertex as usize; + let count = instance.vertex_count as usize; + let vertices = self.vertices.get(first..first + count)?; + if vertices.is_empty() { + return None; + } + let mut sum = Vec3::ZERO; + for vertex in vertices { + sum += Vec3::new(vertex.pos[0], vertex.pos[1], vertex.pos[2]); + } + Some(sum / count as f32) + } + /// Двигает экземпляр модели без сущности. pub fn move_model_instance(&mut self, index: usize, origin: Vec3, angles: Vec3) -> bool { let Some(instance) = self.model_instances.get(index) else { @@ -2827,35 +2870,48 @@ impl AnimData { ) else { continue; }; - // DELTA — каналы уже относительные: поза покоя не прибавляется. - let delta = track.flags & 0x10 != 0; - let euler = |at: usize| -> [f32; 3] { - let mut angles = if delta { [0.0; 3] } else { bone.rot }; - for (j, channel) in track.channels.iter().take(3).enumerate() { + // `CalcBoneQuaternion`: постоянный RAWROT/RAWROT2 важнее дорожек и + // позы покоя; иначе — дорожки углов или поза покоя. + let q = if let Some(raw) = track.raw_rot { + raw + } else { + // DELTA — каналы уже относительные: поза покоя не прибавляется. + let delta = track.flags & formats::mdl::STUDIO_ANIM_DELTA != 0; + let euler = |at: usize| -> [f32; 3] { + let mut angles = if delta { [0.0; 3] } else { bone.rot }; + for (j, channel) in track.channels.iter().take(3).enumerate() { + if let Some(off) = channel { + angles[j] += formats::mdl::Mdl::extract_anim_value(data, *off, at) + * bone.rot_scale[j]; + } + } + angles + }; + let e1 = euler(frame); + let q1 = formats::mdl::Mdl::angle_quaternion(e1[0], e1[1], e1[2]); + if blend > 0.001 { + let e2 = euler(next); + let q2 = formats::mdl::Mdl::angle_quaternion(e2[0], e2[1], e2[2]); + quat_nlerp(q1, q2, blend) + } else { + q1 + } + }; + // `CalcBonePosition`: постоянный RAWPOS — готовые координаты. + let pos = if let Some(raw) = track.raw_pos { + raw + } else { + let delta = track.flags & formats::mdl::STUDIO_ANIM_DELTA != 0; + let mut pos = if delta { [0.0; 3] } else { bone.pos }; + for (j, channel) in track.channels.iter().skip(3).take(3).enumerate() { if let Some(off) = channel { - angles[j] += formats::mdl::Mdl::extract_anim_value(data, *off, at) - * bone.rot_scale[j]; + let v1 = formats::mdl::Mdl::extract_anim_value(data, *off, frame); + let v2 = formats::mdl::Mdl::extract_anim_value(data, *off, next); + pos[j] += (v1 * (1.0 - blend) + v2 * blend) * bone.pos_scale[j]; } } - angles + pos }; - let e1 = euler(frame); - let q1 = formats::mdl::Mdl::angle_quaternion(e1[0], e1[1], e1[2]); - let q = if blend > 0.001 { - let e2 = euler(next); - let q2 = formats::mdl::Mdl::angle_quaternion(e2[0], e2[1], e2[2]); - quat_nlerp(q1, q2, blend) - } else { - q1 - }; - let mut pos = if delta { [0.0; 3] } else { bone.pos }; - for (j, channel) in track.channels.iter().skip(3).take(3).enumerate() { - if let Some(off) = channel { - let v1 = formats::mdl::Mdl::extract_anim_value(data, *off, frame); - let v2 = formats::mdl::Mdl::extract_anim_value(data, *off, next); - pos[j] += (v1 * (1.0 - blend) + v2 * blend) * bone.pos_scale[j]; - } - } slot.0 = Vec3::new(pos[0], pos[1], pos[2]); slot.1 = q; } diff --git a/crates/engine/tests/world_real.rs b/crates/engine/tests/world_real.rs index b5f49be..7db9ff8 100644 --- a/crates/engine/tests/world_real.rs +++ b/crates/engine/tests/world_real.rs @@ -438,12 +438,39 @@ fn real_view_model_skin_stays_coherent() { let bind = instance_positions(&world, index); assert!(world.play_model_animation(index, origin, math::Vec3::ZERO, "idle01", 0.0)); let posed = instance_positions(&world, index); + let centroid = |points: &[[f32; 3]]| -> [f32; 3] { + let mut sum = [0.0f32; 3]; + for p in points { + for (j, value) in sum.iter_mut().enumerate() { + *value += p[j]; + } + } + let count = points.len().max(1) as f32; + [sum[0] / count, sum[1] / count, sum[2] / count] + }; + let (bind_centre, posed_centre) = (centroid(&bind), centroid(&posed)); let mut max_delta = 0.0f32; + // Смещение относительно центра: RAWPOS корня двигает весь меш целиком, + // поэтому «разваливание» меряем после вычитания общего сдвига. + let mut max_centred_delta = 0.0f32; let mut moved = 0usize; for (a, b) in bind.iter().zip(posed.iter()) { let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; let dist = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(); max_delta = max_delta.max(dist); + let da = [ + a[0] - bind_centre[0], + a[1] - bind_centre[1], + a[2] - bind_centre[2], + ]; + let db = [ + b[0] - posed_centre[0], + b[1] - posed_centre[1], + b[2] - posed_centre[2], + ]; + let dd = [da[0] - db[0], da[1] - db[1], da[2] - db[2]]; + max_centred_delta = + max_centred_delta.max((dd[0] * dd[0] + dd[1] * dd[1] + dd[2] * dd[2]).sqrt()); if dist > 4.0 { moved += 1; } @@ -459,15 +486,16 @@ fn real_view_model_skin_stays_coherent() { [max[0] - min[0], max[1] - min[1], max[2] - min[2]] }; eprintln!( - "вьюмодель: {} вершин, сдвинулось >4 юнитов {moved}, макс. сдвиг {max_delta:.1}; \ - габарит покоя {:?}, позы {:?}", + "вьюмодель: {} вершин, сдвинулось >4 юнитов {moved}, макс. сдвиг {max_delta:.1}, \ + после вычитания центра {max_centred_delta:.1}; габарит покоя {:?}, позы {:?}", bind.len(), extent(&bind), extent(&posed) ); + assert!(max_delta < 100.0, "меш уехал: макс. сдвиг {max_delta:.1}"); assert!( - max_delta < 80.0, - "меш разваливается: макс. сдвиг {max_delta:.1}" + max_centred_delta < 40.0, + "меш разваливается: после вычитания центра {max_centred_delta:.1}" ); } @@ -526,7 +554,362 @@ fn real_weapon_model_structure() { } } -/// Позиции вершин экземпляра модели. +/// RAW-флаги дорожек (`RAWPOS`/`RAWROT`/`RAWROT2`) на живых данных. +/// +/// Постоянные дорожки не имеют каналов, поэтому раньше такие кости застревали в +/// позе покоя. Критерии: все встреченные флаги известны; кватернионы единичные; +/// позиции конечны и в масштабе модели; хотя бы одна постоянная дорожка +/// отличается от позы покоя — значит, распаковка действительно применена. +#[test] +fn real_animation_raw_flags_decode() { + use formats::mdl::{ + Mdl, STUDIO_ANIM_ANIMPOS, STUDIO_ANIM_ANIMROT, STUDIO_ANIM_DELTA, STUDIO_ANIM_RAWPOS, + STUDIO_ANIM_RAWROT, STUDIO_ANIM_RAWROT2, + }; + + let Some(root) = assets_root() else { + eprintln!("пропуск: ассеты не найдены"); + return; + }; + let vfs = mount_game(&root); + let known = STUDIO_ANIM_RAWPOS + | STUDIO_ANIM_RAWROT + | STUDIO_ANIM_ANIMPOS + | STUDIO_ANIM_ANIMROT + | STUDIO_ANIM_DELTA + | STUDIO_ANIM_RAWROT2; + + let check_tracks = |tracks: &[formats::mdl::AnimTrack], + bones: &[formats::mdl::Bone], + label: &str| + -> (usize, usize, usize) { + let (mut raw_rot, mut raw_pos, mut changed) = (0usize, 0usize, 0usize); + for track in tracks { + assert_eq!( + track.flags & !known, + 0, + "{label}: неизвестный флаг 0x{:02x}", + track.flags + ); + if let Some(q) = track.raw_rot { + raw_rot += 1; + let len = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt(); + assert!( + (len - 1.0).abs() < 1e-4, + "{label}: кватернион не единичный: {len} (кость {})", + track.bone + ); + assert!( + q.iter().all(|v| v.is_finite()), + "{label}: NaN в кватернионе" + ); + if let Some(bone) = bones.get(track.bone as usize) { + let dot = q[0] * bone.quat[0] + + q[1] * bone.quat[1] + + q[2] * bone.quat[2] + + q[3] * bone.quat[3]; + if dot.abs() < 1.0 - 1e-4 { + changed += 1; + } + } + } + if let Some(p) = track.raw_pos { + raw_pos += 1; + assert!(p.iter().all(|v| v.is_finite()), "{label}: NaN в позиции"); + assert!( + p.iter().all(|v| v.abs() < 1000.0), + "{label}: позиция вне масштаба модели: {p:?}" + ); + } + if track.flags & STUDIO_ANIM_RAWROT != 0 { + assert!( + track.raw_rot.is_some(), + "{label}: RAWROT кости {} не разобран", + track.bone + ); + } + if track.flags & STUDIO_ANIM_RAWROT2 != 0 { + assert!( + track.raw_rot.is_some(), + "{label}: RAWROT2 кости {} не разобран", + track.bone + ); + } + if track.flags & STUDIO_ANIM_RAWPOS != 0 { + assert!( + track.raw_pos.is_some(), + "{label}: RAWPOS кости {} не разобран", + track.bone + ); + } + } + (raw_rot, raw_pos, changed) + }; + + // Пистолет: короткая своя анимация с RAWROT (6 байт) и RAWPOS (half×3). + let data = vfs.read("models/weapons/v_pistol.mdl").expect("v_pistol"); + let mdl = Mdl::parse(&data).expect("разбор v_pistol"); + let anim = mdl + .animations() + .expect("анимации v_pistol") + .into_iter() + .find(|a| a.name.eq_ignore_ascii_case("@idle01")) + .expect("@idle01"); + let start = anim.offset + anim.anim_index.max(0) as usize; + let tracks = Mdl::animation_tracks(&data, start); + let bones = mdl.bones().expect("кости v_pistol"); + let (raw_rot, raw_pos, changed) = check_tracks(&tracks, &bones, "v_pistol @idle01"); + eprintln!( + "v_pistol @idle01: дорожек {}, RAWROT {}, RAWPOS {}, отличаются от позы покоя {}", + tracks.len(), + raw_rot, + raw_pos, + changed + ); + assert_eq!(tracks.len(), 10, "у @idle01 ожидалось 10 дорожек"); + assert!(raw_rot > 0, "в @idle01 нет разобранных RAWROT"); + assert!(raw_pos > 0, "в @idle01 нет разобранных RAWPOS"); + assert!(changed > 0, "постоянные повороты совпали с позой покоя"); + + // Библиотека GMod: 24 дорожки с Quaternion64 (флаг 0x20) из 53. + let Some((data, ani)) = player_anim_library(&vfs) else { + eprintln!("пропуск: библиотека анимаций не разобрана"); + return; + }; + let mdl = Mdl::parse(&data).expect("разбор m_anm"); + let anim = mdl + .animations() + .expect("анимации m_anm") + .into_iter() + .find(|a| a.name.eq_ignore_ascii_case("@taunt_muscle_base")) + .expect("@taunt_muscle_base"); + let blocks = mdl.anim_blocks(); + let start = blocks + .get(anim.anim_block.max(0) as usize) + .map(|b| b.0.max(0) as usize) + .unwrap_or(0) + + anim.anim_index.max(0) as usize; + let tracks = Mdl::animation_tracks(&ani, start); + let bones = mdl.bones().expect("кости m_anm"); + let (raw_rot, raw_pos, changed) = check_tracks(&tracks, &bones, "m_anm @taunt_muscle_base"); + let rawrot2 = tracks + .iter() + .filter(|t| t.flags & STUDIO_ANIM_RAWROT2 != 0) + .count(); + eprintln!( + "m_anm @taunt_muscle_base: дорожек {}, RAWROT2 {}, RAWROT {}, RAWPOS {}, \ + отличаются от позы покоя {}", + tracks.len(), + rawrot2, + raw_rot, + raw_pos, + changed + ); + assert_eq!( + tracks.len(), + 53, + "у @taunt_muscle_base ожидалось 53 дорожки" + ); + assert_eq!(rawrot2, 24, "дорожек с Quaternion64 должно быть 24"); + assert_eq!(raw_rot, 24, "все RAWROT2 должны разбираться в raw_rot"); + assert!(changed > 0, "постоянные повороты совпали с позой покоя"); +} + +/// Имена, которые выбирает `engine::animation`, существуют в библиотеке GMod. +/// +/// Чистая функция проверяется без ассетов, но имя может разойтись с данными +/// (`a_WalkN` против `walk_all`), поэтому сверяем весь набор направлений. +#[test] +fn real_locomotion_animation_names_exist() { + let Some(root) = assets_root() else { + eprintln!("пропуск: ассеты не найдены"); + return; + }; + let vfs = mount_game(&root); + let data = vfs.read("models/m_anm.mdl").expect("m_anm.mdl"); + let mdl = formats::mdl::Mdl::parse(&data).expect("разбор m_anm"); + let names: std::collections::HashSet = mdl + .animations() + .expect("анимации") + .into_iter() + .map(|anim| anim.name.trim_start_matches('@').to_ascii_lowercase()) + .collect(); + + let mut wanted = vec![ + engine::animation::IDLE_ANIMATION.to_string(), + engine::animation::CROUCH_IDLE_ANIMATION + .trim_start_matches('@') + .to_string(), + ]; + for direction in engine::MoveDirection::ALL { + for kind in [ + engine::AnimationKind::Walk, + engine::AnimationKind::Run, + engine::AnimationKind::CrouchWalk, + ] { + wanted.push(kind.animation_name(direction)); + } + } + for name in engine::animation::AIR_ANIMATIONS { + wanted.push(name.trim_start_matches('@').to_string()); + } + + let missing: Vec<&String> = wanted + .iter() + .filter(|name| !names.contains(&name.to_ascii_lowercase())) + .collect(); + eprintln!( + "выбор по состоянию: проверено {} имён, нет в m_anm: {missing:?}", + wanted.len() + ); + assert!( + missing.is_empty(), + "анимаций для выбора состояния нет в библиотеке: {missing:?}" + ); +} + +/// Анимации, которые выбирает состояние, действительно двигают модель игрока. +/// +/// Функция выбора и наличие имён проверяются отдельно; здесь — что каждая +/// выбранная секвенция даёт различимую позу и не разваливает скелет. +#[test] +fn real_player_locomotion_animations_move_vertices() { + 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 origin = math::Vec3::new(0.0, 0.0, 64.0); + let angles = math::Vec3::ZERO; + let mut budget = 512 * 1024 * 1024; + let index = world + .spawn_model_instance( + &vfs, + "models/player/police.mdl", + origin, + angles, + &mut budget, + ) + .expect("модель игрока добавлена"); + + for animation in [ + "a_WalkN", + "a_WalkE", + "a_RunN", + "a_RunW", + "a_CwalkN", + "@cidle_all", + "@jump_fist", + ] { + assert!( + world.play_model_animation(index, origin, angles, animation, 0.0), + "{animation}: анимация не найдена" + ); + let first = instance_positions(&world, index); + assert!( + world.play_model_animation(index, origin, angles, animation, 0.5), + "{animation}: второй кадр не сыграл" + ); + let second = instance_positions(&world, index); + let moved = first + .iter() + .zip(second.iter()) + .filter(|(a, b)| { + let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 0.25 + }) + .count(); + let extent = second.iter().fold(0.0f32, |max, v| { + max.max((v[0] - origin.x).abs()) + .max((v[1] - origin.y).abs()) + .max((v[2] - origin.z).abs()) + }); + eprintln!("{animation}: сдвинулось {moved}, вылет от центра {extent:.1}"); + assert!( + moved > 50, + "{animation}: поза почти не изменилась (сдвинулось {moved})" + ); + assert!( + extent < 120.0, + "{animation}: вершины разлетелись ({extent:.1})" + ); + } +} + +/// Анимация с RAWROT2 двигает вершины: постоянные дорожки больше не держат +/// кости в позе покоя. +#[test] +fn real_player_taunt_moves_vertices_with_raw_tracks() { + 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 origin = math::Vec3::new(0.0, 0.0, 64.0); + let angles = math::Vec3::ZERO; + let mut budget = 512 * 1024 * 1024; + let index = world + .spawn_model_instance( + &vfs, + "models/player/police.mdl", + origin, + angles, + &mut budget, + ) + .expect("модель игрока добавлена"); + assert!( + world.play_model_animation(index, origin, angles, "@taunt_muscle_base", 0.0), + "анимация @taunt_muscle_base не найдена" + ); + let first = instance_positions(&world, index); + assert!(world.play_model_animation(index, origin, angles, "@taunt_muscle_base", 1.0)); + let second = instance_positions(&world, index); + let moved = first + .iter() + .zip(second.iter()) + .filter(|(a, b)| { + let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 0.25 + }) + .count(); + let extent = second.iter().fold(0.0f32, |max, v| { + max.max((v[0] - origin.x).abs()) + .max((v[1] - origin.y).abs()) + .max((v[2] - origin.z).abs()) + }); + eprintln!( + "@taunt_muscle_base: {} вершин, между кадрами 0 и 30 сдвинулось {moved}, \ + максимальный вылет от центра {extent:.1}", + first.len() + ); + assert!( + moved > 50, + "поза с RAWROT2 не меняется: сдвинулось {moved} вершин" + ); + assert!(extent < 120.0, "вершины разлетелись: {extent:.1}"); +} + fn instance_positions(world: &engine::World, index: usize) -> Vec<[f32; 3]> { let instance = &world.model_instances[index]; let first = instance.first_vertex as usize; diff --git a/crates/formats/src/mdl.rs b/crates/formats/src/mdl.rs index d6a44c1..43b8bc6 100644 --- a/crates/formats/src/mdl.rs +++ b/crates/formats/src/mdl.rs @@ -59,15 +59,33 @@ pub enum MdlError { Kv(#[from] crate::kv::KvError), } +/// Флаги `mstudioanim_t` (студия: `STUDIO_ANIM_*`). +/// +/// `RAWPOS`/`RAWROT`/`RAWROT2` — постоянные значения на всю анимацию, лежащие +/// прямо в записи дорожки; остальные — дорожки значений (`mstudioanimvalue_t`). +pub const STUDIO_ANIM_RAWPOS: u8 = 0x01; +pub const STUDIO_ANIM_RAWROT: u8 = 0x02; +pub const STUDIO_ANIM_ANIMPOS: u8 = 0x04; +pub const STUDIO_ANIM_ANIMROT: u8 = 0x08; +pub const STUDIO_ANIM_DELTA: u8 = 0x10; +pub const STUDIO_ANIM_RAWROT2: u8 = 0x20; + /// Дорожка одной кости: шесть каналов (поворот xyz, позиция xyz). /// /// Канал — смещение блока значений (`mstudioanimvalue_t`) в файле кадров; -/// `None` — канал отсутствует и берётся поза покоя кости. +/// `None` — канал отсутствует и берётся поза покоя кости. Постоянные значения +/// (`RAWPOS`/`RAWROT`/`RAWROT2`) разбираются сразу в `raw_pos`/`raw_rot`: +/// у них нет дорожек, поза одна на всю анимацию. #[derive(Debug, Clone)] pub struct AnimTrack { pub bone: u8, pub flags: u8, pub channels: [Option; 6], + /// Готовый кватернион из `Quaternion48`/`Quaternion64` (приоритет у + /// `RAWROT`, как в `CalcBoneQuaternion`). + pub raw_rot: Option<[f32; 4]>, + /// Готовая позиция из `Vector48` (три half-float). + pub raw_pos: Option<[f32; 3]>, } /// Описание анимации (`mstudioanimdesc_t`). @@ -621,7 +639,9 @@ impl<'a> Mdl<'a> { /// `mstudioanim_t` — 4 байта (`bone`, `flags`, `nextoffset`), дорожки /// связаны списком; `nextoffset == 0` — конец. Значения каналов лежат как /// `mstudioanim_valueptr_t` (три смещения) в `mstudioanimvalue_t`; - /// см. `extract_anim_value`. + /// см. `extract_anim_value`. Постоянные значения (`RAW*`) лежат в самой + /// записи дорожки и разбираются здесь же (SDK: `CalcBoneQuaternion`, + /// `CalcBonePosition`). pub fn animation_tracks(data: &[u8], start: usize) -> Vec { let mut out = Vec::new(); let mut offset = start; @@ -631,11 +651,29 @@ impl<'a> Mdl<'a> { let next = i16::from_le_bytes([data[offset + 2], data[offset + 3]]) as i32; let value_base = offset + 4; let mut channels = [None; 6]; - // Поворот: дорожки ANIMROT (три канала углов). RAWROT (готовый - // Quaternion48) и RAWROT2 (Quaternion64) пока не разбираем — тогда - // каналов нет и вызывающий остаётся на позе покоя. - let anim_rot = flags & 0x08 != 0; - let anim_pos = flags & 0x04 != 0; + + // Постоянный поворот: `pQuat48`/`pQuat64` начинаются сразу за + // заголовком дорожки. Если заданы оба флага, SDK берёт Quaternion48. + let raw_rot = if flags & STUDIO_ANIM_RAWROT != 0 { + Self::quaternion48(data.get(value_base..value_base + 6)) + } else if flags & STUDIO_ANIM_RAWROT2 != 0 { + Self::quaternion64(data.get(value_base..value_base + 8)) + } else { + None + }; + + // `pPos` идёт за поворотом с учётом размера обоих RAW-флагов. + let raw_rot_bytes = usize::from(flags & STUDIO_ANIM_RAWROT != 0) * 6 + + usize::from(flags & STUDIO_ANIM_RAWROT2 != 0) * 8; + let raw_pos = if flags & STUDIO_ANIM_RAWPOS != 0 { + Self::vector48(data.get(value_base + raw_rot_bytes..value_base + raw_rot_bytes + 6)) + } else { + None + }; + + // Поворот: дорожки ANIMROT (три канала углов). + let anim_rot = flags & STUDIO_ANIM_ANIMROT != 0; + let anim_pos = flags & STUDIO_ANIM_ANIMPOS != 0; if anim_rot && value_base + 6 <= data.len() { for j in 0..3 { let off = i16::from_le_bytes([ @@ -663,6 +701,8 @@ impl<'a> Mdl<'a> { bone, flags, channels, + raw_rot, + raw_pos, }); if next <= 0 { break; @@ -672,6 +712,52 @@ impl<'a> Mdl<'a> { out } + /// `Quaternion48` (SDK: `compressed_vector.h`): 6 байт, x/y по 16 бит со + /// знаком, z — 15 бит, старший бит — знак `w`. `w` восстанавливается из + /// единичной длины, поэтому кватернион и его отрицание дают один поворот. + pub fn quaternion48(bytes: Option<&[u8]>) -> Option<[f32; 4]> { + let bytes = bytes?; + let x_raw = u16::from_le_bytes([*bytes.first()?, *bytes.get(1)?]); + let y_raw = u16::from_le_bytes([*bytes.get(2)?, *bytes.get(3)?]); + let z_raw = u16::from_le_bytes([*bytes.get(4)?, *bytes.get(5)?]); + let x = (x_raw as i32 - 32768) as f32 * (1.0 / 32768.0); + let y = (y_raw as i32 - 32768) as f32 * (1.0 / 32768.0); + let z = ((z_raw & 0x7fff) as i32 - 16384) as f32 * (1.0 / 16384.0); + let w = unit_w(x, y, z)?; + Some(normalize_quat([ + x, + y, + z, + if z_raw & 0x8000 != 0 { -w } else { w }, + ])) + } + + /// `Quaternion64` (SDK: `compressed_vector.h`): 8 байт, x/y/z по 21 биту, + /// старший бит — знак `w`. + pub fn quaternion64(bytes: Option<&[u8]>) -> Option<[f32; 4]> { + let bytes = bytes?; + let mut raw = [0u8; 8]; + raw.copy_from_slice(bytes.get(..8)?); + let bits = u64::from_le_bytes(raw); + let x = ((bits & 0x1f_ffff) as i32 - 1_048_576) as f32 * (1.0 / 1_048_576.5); + let y = (((bits >> 21) & 0x1f_ffff) as i32 - 1_048_576) as f32 * (1.0 / 1_048_576.5); + let z = (((bits >> 42) & 0x1f_ffff) as i32 - 1_048_576) as f32 * (1.0 / 1_048_576.5); + let w = unit_w(x, y, z)?; + let w = if bits >> 63 != 0 { -w } else { w }; + Some(normalize_quat([x, y, z, w])) + } + + /// `Vector48` (SDK: `compressed_vector.h`): три half-float по 16 бит. + pub fn vector48(bytes: Option<&[u8]>) -> Option<[f32; 3]> { + let bytes = bytes?; + let value = |at: usize| -> Option { + let pair = bytes.get(at..at + 2)?; + Some(half_to_f32(u16::from_le_bytes([pair[0], pair[1]]))) + }; + let out = [value(0)?, value(2)?, value(4)?]; + out.iter().all(|v| v.is_finite()).then_some(out) + } + /// Значение канала на кадре (SDK: `ExtractAnimValue` из `bone_setup.cpp`). /// /// Блок значений: байт `valid` (сколько значений записано), байт `total` @@ -1003,6 +1089,45 @@ fn vec3_at(data: &[u8], offset: usize) -> [f32; 3] { ] } +/// Четвёртая компонента кватерниона из условия единичной длины. +/// +/// Для данных из файла сумма может оказаться больше единицы (битые или +/// округлённые значения): тогда `w` не существует и вызывающий остаётся на +/// позе покоя, а не получает NaN. +fn unit_w(x: f32, y: f32, z: f32) -> Option { + let w2 = 1.0 - x * x - y * y - z * z; + (w2 >= 0.0 && w2.is_finite()).then(|| w2.sqrt()) +} + +/// Нормировка кватерниона после распаковки: квантование даёт длину, +/// отличную от единицы на десятые доли процента. +fn normalize_quat(q: [f32; 4]) -> [f32; 4] { + let len = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt(); + if len > 1e-6 && len.is_finite() { + [q[0] / len, q[1] / len, q[2] / len, q[3] / len] + } else { + [0.0, 0.0, 0.0, 1.0] + } +} + +/// Half-float (`float16` из SDK) → `f32`. +/// +/// В `Vector48` обычные числа, поэтому хватает стандартного IEEE 754 half: +/// бесконечность в SDK превращается в максимальное значение, NaN — в ноль. +fn half_to_f32(bits: u16) -> f32 { + let sign = if bits & 0x8000 != 0 { -1.0 } else { 1.0 }; + let exponent = (bits >> 10) & 0x1f; + let mantissa = (bits & 0x3ff) as f32; + match exponent { + 0 if mantissa == 0.0 => sign * 0.0, + // Субнормальное число: 2^-14 · mantissa/1024. + 0 => sign * mantissa * (1.0 / 1024.0) * (1.0 / 16384.0), + 31 if mantissa == 0.0 => sign * 65504.0, + 31 => 0.0, + _ => sign * (1.0 + mantissa / 1024.0) * 2f32.powi(exponent as i32 - 15), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1094,6 +1219,87 @@ mod tests { } } + #[test] + fn raw_quaternion_and_vector_decode() { + // Quaternion48: x=0.5, y=-0.5, z=0, w>0. + let x = (0.5f32 * 32768.0 + 32768.0) as u16; + let y = (-0.5f32 * 32768.0 + 32768.0) as u16; + let z = 16384u16; // z = 0 + let mut bytes = Vec::new(); + bytes.extend_from_slice(&x.to_le_bytes()); + bytes.extend_from_slice(&y.to_le_bytes()); + bytes.extend_from_slice(&z.to_le_bytes()); + let q = Mdl::quaternion48(Some(&bytes)).expect("Quaternion48"); + assert!((q[0] - 0.5).abs() < 1e-4, "{q:?}"); + assert!((q[1] + 0.5).abs() < 1e-4, "{q:?}"); + assert!(q[2].abs() < 1e-4, "{q:?}"); + assert!((q[3] - 0.5f32.sqrt()).abs() < 1e-4, "{q:?}"); + // Знак w — старший бит z. + let mut neg = bytes.clone(); + neg[4..6].copy_from_slice(&(z | 0x8000).to_le_bytes()); + let qn = Mdl::quaternion48(Some(&neg)).expect("Quaternion48 с -w"); + assert!((qn[3] + 0.5f32.sqrt()).abs() < 1e-4, "{qn:?}"); + + // Quaternion64: единичный поворот. + let raw = 1_048_576u64 | (1_048_576u64 << 21) | (1_048_576u64 << 42); + let q = Mdl::quaternion64(Some(&raw.to_le_bytes())).expect("Quaternion64"); + assert!((q[3] - 1.0).abs() < 1e-5, "{q:?}"); + + // Vector48: half-float 1, 2, -0.5. + let mut vec = Vec::new(); + for half in [0x3c00u16, 0x4000, 0xb800] { + vec.extend_from_slice(&half.to_le_bytes()); + } + let p = Mdl::vector48(Some(&vec)).expect("Vector48"); + assert!((p[0] - 1.0).abs() < 1e-6, "{p:?}"); + assert!((p[1] - 2.0).abs() < 1e-6, "{p:?}"); + assert!((p[2] + 0.5).abs() < 1e-6, "{p:?}"); + + // Обрезанные данные — None, без паники. + assert!(Mdl::quaternion48(Some(&bytes[..5])).is_none()); + assert!(Mdl::quaternion64(Some(&[0u8; 7])).is_none()); + assert!(Mdl::vector48(Some(&vec[..5])).is_none()); + } + + #[test] + fn animation_tracks_parse_raw_values() { + // Дорожка 0: RAWPOS|RAWROT, далее Quaternion48 (identity) и Vector48. + // Дорожка 1: RAWROT2 через nextoffset; Quaternion64 (identity). + let mut data = vec![0u8; 4 + 6 + 6 + 4 + 8]; + data[0] = 3; // bone + data[1] = STUDIO_ANIM_RAWPOS | STUDIO_ANIM_RAWROT; + data[2..4].copy_from_slice(&16i16.to_le_bytes()); // nextoffset + // Quaternion48 identity: x=32768, y=32768, z=16384. + data[4..6].copy_from_slice(&32768u16.to_le_bytes()); + data[6..8].copy_from_slice(&32768u16.to_le_bytes()); + data[8..10].copy_from_slice(&16384u16.to_le_bytes()); + // Vector48: 0, 0, 38.5 (half 0x50D0 = 38.5). + data[10..12].copy_from_slice(&0u16.to_le_bytes()); + data[12..14].copy_from_slice(&0u16.to_le_bytes()); + data[14..16].copy_from_slice(&0x50d0u16.to_le_bytes()); + // Вторая дорожка. + data[16] = 7; + data[17] = STUDIO_ANIM_RAWROT2; + data[18..20].copy_from_slice(&0i16.to_le_bytes()); + let raw = 1_048_576u64 | (1_048_576u64 << 21) | (1_048_576u64 << 42); + data[20..28].copy_from_slice(&raw.to_le_bytes()); + + let tracks = Mdl::animation_tracks(&data, 0); + assert_eq!(tracks.len(), 2); + assert_eq!(tracks[0].bone, 3); + assert_eq!(tracks[0].flags & STUDIO_ANIM_RAWROT, STUDIO_ANIM_RAWROT); + assert!((tracks[0].raw_rot.expect("raw rot")[3] - 1.0).abs() < 1e-5); + assert!((tracks[0].raw_pos.expect("raw pos")[2] - 38.5).abs() < 1e-4); + assert_eq!(tracks[1].bone, 7); + assert!((tracks[1].raw_rot.expect("raw rot2")[3] - 1.0).abs() < 1e-5); + + // Обрезанная запись: дорожка читается, но неполный RAW не разбирается. + let short = Mdl::animation_tracks(&data[..8], 0); + assert_eq!(short.len(), 1); + assert!(short[0].raw_rot.is_none()); + assert!(short[0].raw_pos.is_none()); + } + #[test] fn garbage_never_panics() { let mut state: u32 = 0xDEAD_BEEF; diff --git a/docs/DIARY.md b/docs/DIARY.md index e960553..b537eca 100644 --- a/docs/DIARY.md +++ b/docs/DIARY.md @@ -4,7 +4,7 @@ уплотнения контекста ничего не терялось. Коротко: состояние, замеры, следующий шаг, грабли. -Обновлён: 2026-10-16, вечер. +Обновлён: 2026-10-23, день. --- @@ -150,10 +150,25 @@ origin. Посадка: глаз + 20 вперёд + 6 вправо − 10 вн `ModelVertex` и CPU-скиннинга. Заливка вершин на GPU для этого уже готова — тот же путь `moved_ranges`, что у движущихся пропов. +**A26, сделано 2026-10-23 (этот заход):** постоянные дорожки +`RAWROT`/`RAWROT2`/`RAWPOS` разобраны по `compressed_vector.h` (Quaternion48 6 +байт, Quaternion64 8, Vector48 — три half-float). Замеры: `v_pistol @idle01` — +10 дорожек, 7 RAWROT + 2 RAWPOS; `m_anm @taunt_muscle_base` — 53 дорожки, 24 +RAWROT2, кватернионы единичные, неизвестных флагов нет. `@taunt` теперь +двигает 3145 из 3147 вершин между кадрами 0 и 30. + +Выбор анимации по состоянию — `engine::animation`: `idle_all_01`, `a_WalkN`, +`a_RunN`, `@cidle_all`, `a_CwalkN`, `@jump_fist` (первая существующая), восемь +направлений относительно взгляда. Фаза walk↔run сохраняется, смена типа — +сброс. Клиент играет выбранное имя; вьюмодель качается при ходьбе и отстаёт +при повороте, ЛКМ без захвата проигрывает `fire`. Проверки: +`real_animation_raw_flags_decode`, `real_player_taunt_moves_vertices_with_raw_tracks`, +юнит-тесты `engine::animation`. + **Следующее:** (а) до-аллокация вершин под спавн вместо пересборки рендерера; -(б) реестр SWEP из Lua и вьюмодель; (в) вращение пропа колесом и заморозка у -физгана; (г) скрытие своей модели, когда камера внутри неё; (д) скелет+анимации -моделей; (е) консольные команды спавна на данных из Lua (`gm_spawn`). +(б) блендинг анимаций игрока и вьюмодели; (в) вращение пропа колесом и +заморозка у физгана; (г) оружие в руках связывает `@idle_*`/`@jump_*` и +вьюмодель; (д) консольные команды спавна на данных из Lua (`gm_spawn`). ## 3. Что уже сделано и лежит в `main` diff --git a/docs/PARITY.md b/docs/PARITY.md index ab52e17..68d76f2 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -3262,9 +3262,51 @@ GPU» работает целиком. Пока это не анимация: н добавлен вид со стороны `--third-person` — без него собственную модель не видно (в первом лице камера внутри неё, и она уезжает под карту). -**Остаток по A26:** флаги RAWROT/RAWROT2 (постоянный поворот дорожки) пока не -разбираются — такие кости остаются в позе покоя (в `@taunt_muscle_base` 24 -дорожки из 53); выбор секвенции по состоянию (`ACT_MP_*`) и блендинг между ними. +**Сделано (постоянные дорожки RAW, 2026-10-23).** Разобраны все три вида +постоянных значений `mstudioanim_t` — код взят по спеке `compressed_vector.h`: + +- `STUDIO_ANIM_RAWROT` (0x02) — `Quaternion48`: 6 байт, x/y по 16 бит со + смещением 32768 и шагом 1/32768, z — 15 бит со смещением 16384 (1/16384), + старший бит — знак `w`; `w` восстанавливается из единичной длины; +- `STUDIO_ANIM_RAWROT2` (0x20) — `Quaternion64`: 8 байт, x/y/z по 21 биту + (`(raw − 1048576) / 1048576.5`), старший бит — знак `w`; +- `STUDIO_ANIM_RAWPOS` (0x01) — `Vector48`: три half-float; `pPos` идёт за + Quaternion48/64 (смещение учитывает оба флага). + +`AnimTrack` теперь несёт готовые `raw_rot`/`raw_pos`, а `frame_pose` отдаёт им +приоритет над дорожками и позой покоя (как `CalcBoneQuaternion` / +`CalcBonePosition`). Замеры на живых данных (`real_animation_raw_flags_decode`): +`v_pistol @idle01` — 10 дорожек, 7 RAWROT и 2 RAWPOS, у 7 повороты отличаются +от позы покоя; `m_anm @taunt_muscle_base` — 53 дорожки, **24 RAWROT2**, все +кватернионы единичные, неизвестных флагов нет. Анимация `@taunt_muscle_base` +теперь двигает 3145 вершин из 3147 между кадрами 0 и 30 (тест +`real_player_taunt_moves_vertices_with_raw_tracks`), вылет от центра 63,7 +юнита — скелет не разваливается. + +**Сделано (выбор анимации по состоянию и вьюмодель, 2026-10-23).** +`engine::animation` — чистая функция выбора и состояние фазы: + +- стоя — `idle_all_01`; шаг — `a_Walk`; бег — `a_Run` (флаг `sprint` + или скорость ≥ 320); присед — `@cidle_all` / `a_Cwalk`; в воздухе — + первая существующая из `@jump_fist`, `@jump_pistol`, `a_gmod_jump_delta`, иначе + остаётся текущая; +- восемь направлений (N/NE/E/SE/S/SW/W/NW) считаются по горизонтальной + скорости относительно курса взгляда: стрейф вправо даёт `E`, влево — `W`; +- walk ↔ run и смена направления сохраняют фазу, смена типа (стоя/присед/ + воздух) сбрасывает время; если анимации нет в модели, остаётся текущая. + +Клиент (`crates/client/src/map.rs`) держит `AnimationState` и играет выбранное +имя. Вьюмодель получила покачивание при ходьбе (амплитуда от скорости) и +инерционный сдвиг при повороте; ЛКМ без тела под прицелом проигрывает +одноразовую `fire`/`attack_player`, если она есть у модели. Привязка вьюмодели +идёт к центру **анимированной** позы: у `v_pistol` `RAWPOS` корня сдвигает меш +на 68 юнитов, и без поправки ствол уезжает из кадра. Проверка выбора — +юнит-тесты `engine::animation`; кадры клиента — в отчёте задачи. + +**Остаток по A26:** блендинг между секвенциями (сейчас переключение жёсткое), +направление взгляда/корпуса отдельно от движения, оружие в руках влияет на +`@jump_*`/`@idle_*` (сейчас кулаки/общие), блендинг вьюмодели между +состояниями. **Замер по масштабу кости (историческая заметка).** У кватерниона модуль вектора поворота не больше единицы у **всех** костей и кадров — это и есть