A26: постоянные дорожки RAW и выбор анимации по состоянию
- formats: Quaternion48/Quaternion64/Vector48 (RAWROT, RAWROT2, RAWPOS), приоритет постоянных значений в frame_pose; - engine::animation: выбор idle/walk/run/crouch/jump по скорости, приседу, земле и восьми направлениям относительно взгляда, сохранение фазы walk↔run; - клиент: модель игрока играет выбранное имя, вьюмодель качается на ходу, отстаёт при повороте и проигрывает fire по ЛКМ; центр анимированной позы компенсирует RAWPOS корня; - тесты: RAW на v_pistol/m_anm, имена и проигрывание секвенций, чистый выбор и фаза; docs A26 обновлены.
This commit is contained in:
parent
f58eafeb46
commit
d42d6464f6
8 changed files with 1312 additions and 55 deletions
|
|
@ -498,6 +498,39 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
||||||
// проверять физган кадром и скриптом, а не руками.
|
// проверять физган кадром и скриптом, а не руками.
|
||||||
let auto_grab = std::env::var_os("SOURCE_RS_PHYSGUN").is_some();
|
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<f32> = 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 {
|
'running: loop {
|
||||||
for event in events.poll_iter() {
|
for event in events.poll_iter() {
|
||||||
match event {
|
match event {
|
||||||
|
|
@ -539,6 +572,14 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
||||||
while log.len() > 8 {
|
while log.len() > 8 {
|
||||||
log.pop_front();
|
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 {
|
Event::MouseButtonUp {
|
||||||
|
|
@ -664,6 +705,12 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
||||||
let dt = last.elapsed().as_secs_f32().min(0.1);
|
let dt = last.elapsed().as_secs_f32().min(0.1);
|
||||||
last = Instant::now();
|
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;
|
wave_time += dt;
|
||||||
|
|
@ -788,6 +835,52 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
||||||
camera.pos = camera.pos - forward * 128.0 + math::Vec3::new(0.0, 0.0, 32.0);
|
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`.
|
// (жалоба пользователя). Раньше это шло до `player.update`.
|
||||||
|
|
@ -807,10 +900,30 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
||||||
angles.y,
|
angles.y,
|
||||||
angles.z,
|
angles.z,
|
||||||
);
|
);
|
||||||
let origin = eye + forward * view_distance + right * 5.0
|
let lateral = 5.0 + bob_right + view_sway_right;
|
||||||
- math::Vec3::new(0.0, 0.0, 8.0)
|
let vertical = -8.0 + bob_up + view_sway_up;
|
||||||
- centre;
|
let target = eye
|
||||||
w.play_model_animation(index, origin, angles, "idle01", wave_time);
|
+ 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 {
|
if let Some(index) = player_model_instance {
|
||||||
let inside = camera.pos.distance(player.eye_position()) < 48.0;
|
let inside = camera.pos.distance(player.eye_position()) < 48.0;
|
||||||
|
|
@ -819,13 +932,17 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
||||||
} else {
|
} else {
|
||||||
player.position
|
player.position
|
||||||
};
|
};
|
||||||
w.play_model_animation(
|
let angles = math::Vec3::new(0.0, camera.yaw + 180.0, 0.0);
|
||||||
index,
|
match &player_pose {
|
||||||
origin,
|
Some(pose) => {
|
||||||
math::Vec3::new(0.0, camera.yaw + 180.0, 0.0),
|
w.play_model_animation(index, origin, angles, &pose.name, pose.time);
|
||||||
"idle_all_01",
|
}
|
||||||
wave_time,
|
// Анимации нет — экземпляр всё равно обязан стоять на месте
|
||||||
);
|
// игрока, иначе после движения останется в старой точке.
|
||||||
|
None => {
|
||||||
|
w.move_model_instance(index, origin, angles);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
moved_ranges = w.take_moved_ranges();
|
moved_ranges = w.take_moved_ranges();
|
||||||
moved_index_ranges = w.take_moved_index_ranges();
|
moved_index_ranges = w.take_moved_index_ranges();
|
||||||
|
|
|
||||||
433
crates/engine/src/animation.rs
Normal file
433
crates/engine/src/animation.rs
Normal file
|
|
@ -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<AnimationKind>,
|
||||||
|
name: String,
|
||||||
|
time: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnimationState {
|
||||||
|
/// Обновляет выбор на `dt` секунд.
|
||||||
|
///
|
||||||
|
/// `available` сообщает, есть ли анимация в модели: если выбранной нет,
|
||||||
|
/// остаётся текущая (для прыжка перебираются `AIR_ANIMATIONS`). Возвращает
|
||||||
|
/// `None`, только когда играть нечего вовсе.
|
||||||
|
pub fn update<F>(
|
||||||
|
&mut self,
|
||||||
|
dt: f32,
|
||||||
|
context: AnimationContext,
|
||||||
|
available: F,
|
||||||
|
) -> Option<AnimationPose>
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
//! `engine` — клиентская логика мира: сборка геометрии из BSP, камера,
|
//! `engine` — клиентская логика мира: сборка геометрии из BSP, камера,
|
||||||
//! система сущностей и игрок.
|
//! система сущностей и игрок.
|
||||||
|
|
||||||
|
pub mod animation;
|
||||||
pub mod camera;
|
pub mod camera;
|
||||||
pub mod constraints;
|
pub mod constraints;
|
||||||
pub mod entity;
|
pub mod entity;
|
||||||
|
|
@ -9,6 +10,10 @@ pub mod physics;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
pub mod world;
|
pub mod world;
|
||||||
|
|
||||||
|
pub use animation::{
|
||||||
|
AnimationChoice, AnimationContext, AnimationKind, AnimationPose, AnimationState, MoveDirection,
|
||||||
|
select_animation,
|
||||||
|
};
|
||||||
pub use camera::FlyCamera;
|
pub use camera::FlyCamera;
|
||||||
pub use entity::{Entity, EntityFlags, EntityIndex, EntityWorld, MoveType};
|
pub use entity::{Entity, EntityFlags, EntityIndex, EntityWorld, MoveType};
|
||||||
pub use model::{Model, ModelVertex, load_model};
|
pub use model::{Model, ModelVertex, load_model};
|
||||||
|
|
|
||||||
|
|
@ -2452,6 +2452,30 @@ impl World {
|
||||||
Some(self.props.len() - 1)
|
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<f32> {
|
||||||
|
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).
|
/// Двигает экземпляр и переписывает вершины по кадрам анимации (A26).
|
||||||
///
|
///
|
||||||
/// `animation` — имя анимации (у моделей GMod библиотека анимаций лежит во
|
/// `animation` — имя анимации (у моделей GMod библиотека анимаций лежит во
|
||||||
|
|
@ -2576,6 +2600,25 @@ impl World {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Центр текущих (уже скиненных) вершин экземпляра в мировых координатах.
|
||||||
|
///
|
||||||
|
/// Нужен вьюмодели: анимация с `RAWPOS` корня сдвигает весь меш, и
|
||||||
|
/// привязка к центру габарита должна учитывать позу, а не позу покоя.
|
||||||
|
pub fn model_instance_center(&self, index: usize) -> Option<Vec3> {
|
||||||
|
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 {
|
pub fn move_model_instance(&mut self, index: usize, origin: Vec3, angles: Vec3) -> bool {
|
||||||
let Some(instance) = self.model_instances.get(index) else {
|
let Some(instance) = self.model_instances.get(index) else {
|
||||||
|
|
@ -2827,35 +2870,48 @@ impl AnimData {
|
||||||
) else {
|
) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// DELTA — каналы уже относительные: поза покоя не прибавляется.
|
// `CalcBoneQuaternion`: постоянный RAWROT/RAWROT2 важнее дорожек и
|
||||||
let delta = track.flags & 0x10 != 0;
|
// позы покоя; иначе — дорожки углов или поза покоя.
|
||||||
let euler = |at: usize| -> [f32; 3] {
|
let q = if let Some(raw) = track.raw_rot {
|
||||||
let mut angles = if delta { [0.0; 3] } else { bone.rot };
|
raw
|
||||||
for (j, channel) in track.channels.iter().take(3).enumerate() {
|
} 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 {
|
if let Some(off) = channel {
|
||||||
angles[j] += formats::mdl::Mdl::extract_anim_value(data, *off, at)
|
let v1 = formats::mdl::Mdl::extract_anim_value(data, *off, frame);
|
||||||
* bone.rot_scale[j];
|
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.0 = Vec3::new(pos[0], pos[1], pos[2]);
|
||||||
slot.1 = q;
|
slot.1 = q;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -438,12 +438,39 @@ fn real_view_model_skin_stays_coherent() {
|
||||||
let bind = instance_positions(&world, index);
|
let bind = instance_positions(&world, index);
|
||||||
assert!(world.play_model_animation(index, origin, math::Vec3::ZERO, "idle01", 0.0));
|
assert!(world.play_model_animation(index, origin, math::Vec3::ZERO, "idle01", 0.0));
|
||||||
let posed = instance_positions(&world, index);
|
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;
|
let mut max_delta = 0.0f32;
|
||||||
|
// Смещение относительно центра: RAWPOS корня двигает весь меш целиком,
|
||||||
|
// поэтому «разваливание» меряем после вычитания общего сдвига.
|
||||||
|
let mut max_centred_delta = 0.0f32;
|
||||||
let mut moved = 0usize;
|
let mut moved = 0usize;
|
||||||
for (a, b) in bind.iter().zip(posed.iter()) {
|
for (a, b) in bind.iter().zip(posed.iter()) {
|
||||||
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
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();
|
let dist = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||||
max_delta = max_delta.max(dist);
|
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 {
|
if dist > 4.0 {
|
||||||
moved += 1;
|
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]]
|
[max[0] - min[0], max[1] - min[1], max[2] - min[2]]
|
||||||
};
|
};
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"вьюмодель: {} вершин, сдвинулось >4 юнитов {moved}, макс. сдвиг {max_delta:.1}; \
|
"вьюмодель: {} вершин, сдвинулось >4 юнитов {moved}, макс. сдвиг {max_delta:.1}, \
|
||||||
габарит покоя {:?}, позы {:?}",
|
после вычитания центра {max_centred_delta:.1}; габарит покоя {:?}, позы {:?}",
|
||||||
bind.len(),
|
bind.len(),
|
||||||
extent(&bind),
|
extent(&bind),
|
||||||
extent(&posed)
|
extent(&posed)
|
||||||
);
|
);
|
||||||
|
assert!(max_delta < 100.0, "меш уехал: макс. сдвиг {max_delta:.1}");
|
||||||
assert!(
|
assert!(
|
||||||
max_delta < 80.0,
|
max_centred_delta < 40.0,
|
||||||
"меш разваливается: макс. сдвиг {max_delta:.1}"
|
"меш разваливается: после вычитания центра {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<String> = 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]> {
|
fn instance_positions(world: &engine::World, index: usize) -> Vec<[f32; 3]> {
|
||||||
let instance = &world.model_instances[index];
|
let instance = &world.model_instances[index];
|
||||||
let first = instance.first_vertex as usize;
|
let first = instance.first_vertex as usize;
|
||||||
|
|
|
||||||
|
|
@ -59,15 +59,33 @@ pub enum MdlError {
|
||||||
Kv(#[from] crate::kv::KvError),
|
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).
|
/// Дорожка одной кости: шесть каналов (поворот xyz, позиция xyz).
|
||||||
///
|
///
|
||||||
/// Канал — смещение блока значений (`mstudioanimvalue_t`) в файле кадров;
|
/// Канал — смещение блока значений (`mstudioanimvalue_t`) в файле кадров;
|
||||||
/// `None` — канал отсутствует и берётся поза покоя кости.
|
/// `None` — канал отсутствует и берётся поза покоя кости. Постоянные значения
|
||||||
|
/// (`RAWPOS`/`RAWROT`/`RAWROT2`) разбираются сразу в `raw_pos`/`raw_rot`:
|
||||||
|
/// у них нет дорожек, поза одна на всю анимацию.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AnimTrack {
|
pub struct AnimTrack {
|
||||||
pub bone: u8,
|
pub bone: u8,
|
||||||
pub flags: u8,
|
pub flags: u8,
|
||||||
pub channels: [Option<usize>; 6],
|
pub channels: [Option<usize>; 6],
|
||||||
|
/// Готовый кватернион из `Quaternion48`/`Quaternion64` (приоритет у
|
||||||
|
/// `RAWROT`, как в `CalcBoneQuaternion`).
|
||||||
|
pub raw_rot: Option<[f32; 4]>,
|
||||||
|
/// Готовая позиция из `Vector48` (три half-float).
|
||||||
|
pub raw_pos: Option<[f32; 3]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Описание анимации (`mstudioanimdesc_t`).
|
/// Описание анимации (`mstudioanimdesc_t`).
|
||||||
|
|
@ -621,7 +639,9 @@ impl<'a> Mdl<'a> {
|
||||||
/// `mstudioanim_t` — 4 байта (`bone`, `flags`, `nextoffset`), дорожки
|
/// `mstudioanim_t` — 4 байта (`bone`, `flags`, `nextoffset`), дорожки
|
||||||
/// связаны списком; `nextoffset == 0` — конец. Значения каналов лежат как
|
/// связаны списком; `nextoffset == 0` — конец. Значения каналов лежат как
|
||||||
/// `mstudioanim_valueptr_t` (три смещения) в `mstudioanimvalue_t`;
|
/// `mstudioanim_valueptr_t` (три смещения) в `mstudioanimvalue_t`;
|
||||||
/// см. `extract_anim_value`.
|
/// см. `extract_anim_value`. Постоянные значения (`RAW*`) лежат в самой
|
||||||
|
/// записи дорожки и разбираются здесь же (SDK: `CalcBoneQuaternion`,
|
||||||
|
/// `CalcBonePosition`).
|
||||||
pub fn animation_tracks(data: &[u8], start: usize) -> Vec<AnimTrack> {
|
pub fn animation_tracks(data: &[u8], start: usize) -> Vec<AnimTrack> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut offset = start;
|
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 next = i16::from_le_bytes([data[offset + 2], data[offset + 3]]) as i32;
|
||||||
let value_base = offset + 4;
|
let value_base = offset + 4;
|
||||||
let mut channels = [None; 6];
|
let mut channels = [None; 6];
|
||||||
// Поворот: дорожки ANIMROT (три канала углов). RAWROT (готовый
|
|
||||||
// Quaternion48) и RAWROT2 (Quaternion64) пока не разбираем — тогда
|
// Постоянный поворот: `pQuat48`/`pQuat64` начинаются сразу за
|
||||||
// каналов нет и вызывающий остаётся на позе покоя.
|
// заголовком дорожки. Если заданы оба флага, SDK берёт Quaternion48.
|
||||||
let anim_rot = flags & 0x08 != 0;
|
let raw_rot = if flags & STUDIO_ANIM_RAWROT != 0 {
|
||||||
let anim_pos = flags & 0x04 != 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() {
|
if anim_rot && value_base + 6 <= data.len() {
|
||||||
for j in 0..3 {
|
for j in 0..3 {
|
||||||
let off = i16::from_le_bytes([
|
let off = i16::from_le_bytes([
|
||||||
|
|
@ -663,6 +701,8 @@ impl<'a> Mdl<'a> {
|
||||||
bone,
|
bone,
|
||||||
flags,
|
flags,
|
||||||
channels,
|
channels,
|
||||||
|
raw_rot,
|
||||||
|
raw_pos,
|
||||||
});
|
});
|
||||||
if next <= 0 {
|
if next <= 0 {
|
||||||
break;
|
break;
|
||||||
|
|
@ -672,6 +712,52 @@ impl<'a> Mdl<'a> {
|
||||||
out
|
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<f32> {
|
||||||
|
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`).
|
/// Значение канала на кадре (SDK: `ExtractAnimValue` из `bone_setup.cpp`).
|
||||||
///
|
///
|
||||||
/// Блок значений: байт `valid` (сколько значений записано), байт `total`
|
/// Блок значений: байт `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<f32> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn garbage_never_panics() {
|
fn garbage_never_panics() {
|
||||||
let mut state: u32 = 0xDEAD_BEEF;
|
let mut state: u32 = 0xDEAD_BEEF;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
уплотнения контекста ничего не терялось. Коротко: состояние, замеры, следующий
|
уплотнения контекста ничего не терялось. Коротко: состояние, замеры, следующий
|
||||||
шаг, грабли.
|
шаг, грабли.
|
||||||
|
|
||||||
Обновлён: 2026-10-16, вечер.
|
Обновлён: 2026-10-23, день.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -150,10 +150,25 @@ origin. Посадка: глаз + 20 вперёд + 6 вправо − 10 вн
|
||||||
`ModelVertex` и CPU-скиннинга. Заливка вершин на GPU для этого уже готова —
|
`ModelVertex` и CPU-скиннинга. Заливка вершин на GPU для этого уже готова —
|
||||||
тот же путь `moved_ranges`, что у движущихся пропов.
|
тот же путь `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 и вьюмодель; (в) вращение пропа колесом и заморозка у
|
(б) блендинг анимаций игрока и вьюмодели; (в) вращение пропа колесом и
|
||||||
физгана; (г) скрытие своей модели, когда камера внутри неё; (д) скелет+анимации
|
заморозка у физгана; (г) оружие в руках связывает `@idle_*`/`@jump_*` и
|
||||||
моделей; (е) консольные команды спавна на данных из Lua (`gm_spawn`).
|
вьюмодель; (д) консольные команды спавна на данных из Lua (`gm_spawn`).
|
||||||
|
|
||||||
## 3. Что уже сделано и лежит в `main`
|
## 3. Что уже сделано и лежит в `main`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3262,9 +3262,51 @@ GPU» работает целиком. Пока это не анимация: н
|
||||||
добавлен вид со стороны `--third-person` — без него собственную модель не видно
|
добавлен вид со стороны `--third-person` — без него собственную модель не видно
|
||||||
(в первом лице камера внутри неё, и она уезжает под карту).
|
(в первом лице камера внутри неё, и она уезжает под карту).
|
||||||
|
|
||||||
**Остаток по A26:** флаги RAWROT/RAWROT2 (постоянный поворот дорожки) пока не
|
**Сделано (постоянные дорожки RAW, 2026-10-23).** Разобраны все три вида
|
||||||
разбираются — такие кости остаются в позе покоя (в `@taunt_muscle_base` 24
|
постоянных значений `mstudioanim_t` — код взят по спеке `compressed_vector.h`:
|
||||||
дорожки из 53); выбор секвенции по состоянию (`ACT_MP_*`) и блендинг между ними.
|
|
||||||
|
- `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<dir>`; бег — `a_Run<dir>` (флаг `sprint`
|
||||||
|
или скорость ≥ 320); присед — `@cidle_all` / `a_Cwalk<dir>`; в воздухе —
|
||||||
|
первая существующая из `@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_*` (сейчас кулаки/общие), блендинг вьюмодели между
|
||||||
|
состояниями.
|
||||||
|
|
||||||
**Замер по масштабу кости (историческая заметка).** У кватерниона модуль
|
**Замер по масштабу кости (историческая заметка).** У кватерниона модуль
|
||||||
вектора поворота не больше единицы у **всех** костей и кадров — это и есть
|
вектора поворота не больше единицы у **всех** костей и кадров — это и есть
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue