From 8c5b852613ab691dd747de8b6b2ce2a625622db7 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Sat, 24 Oct 2026 20:18:56 +0300 Subject: [PATCH] =?UTF-8?q?Sandbox:=20=D1=84=D0=B8=D0=B7=D0=B3=D0=B0=D0=BD?= =?UTF-8?q?,=20freeze=20=D0=B8=20undo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - физган отдельным режимом F: захват лучом, плавное удержание скоростью, поворот колесом, freeze по ПКМ, дальность 250 и масса до 500 - луч/крест в отладочном слое и жёлтый маркер замороженного пропа - undo-стек на 32 действия (Z/Shift+Z): спавн, перемещение, freeze - World::remove_prop/restore_prop: удаление тела и сущности, скрытие меша - env-проверки SOURCE_RS_TEST_PHYSGUN/UNDO/REDO, подсказки HUD - тесты физики/мира, обновлены docs/DIARY и docs/PARITY --- crates/client/src/lib.rs | 1 + crates/client/src/map.rs | 561 ++++++++++++++++++++++++------- crates/client/src/sandbox.rs | 504 +++++++++++++++++++++++++++ crates/engine/src/physics.rs | 234 ++++++++++++- crates/engine/src/player.rs | 5 + crates/engine/src/world.rs | 290 ++++++++++++++++ crates/render/src/debug_lines.rs | 4 + docs/DIARY.md | 48 +++ docs/PARITY.md | 8 + 9 files changed, 1532 insertions(+), 123 deletions(-) create mode 100644 crates/client/src/sandbox.rs diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 38a441c..79db065 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -10,6 +10,7 @@ mod menu; mod net_cmd; mod pack; mod qmenu; +mod sandbox; mod touch; mod ui_overlay; mod weapon; diff --git a/crates/client/src/map.rs b/crates/client/src/map.rs index dd1a896..945216a 100644 --- a/crates/client/src/map.rs +++ b/crates/client/src/map.rs @@ -2,6 +2,7 @@ use crate::doctor; use crate::qmenu::{self, MenuAction, SpawnItem, SpawnMenu}; +use crate::sandbox::{self, SandboxAction, UndoStack}; use crate::touch::TouchControls; use crate::ui_overlay; use anyhow::Context; @@ -29,22 +30,45 @@ fn debug_collision_radius() -> Option { Some(value.parse::().unwrap_or(500.0).clamp(16.0, 4000.0)) } -/// Готовит линии отладочного слоя вокруг точки (если слой включён). -fn upload_debug_collision( +/// Готовит линии отладочного слоя вокруг точки (если слой включён) и +/// добавляет к ним произвольные линии кадра (луч физгана). +fn upload_debug_layer( renderer: &mut WorldRenderer, gfx: &Gfx, collision: &engine::Collision, center: math::Vec3, radius: Option, body: Option<&math::Aabb>, + extra: &[engine::DebugLine], ) { - let Some(radius) = radius else { - return; + let mut lines = match radius { + Some(radius) => collision.debug_lines(center, radius, body), + None => Vec::new(), }; - let lines = collision.debug_lines(center, radius, body); + lines.extend_from_slice(extra); let vertices = render::debug_lines::vertices(&lines); renderer.set_debug_lines(&gfx.device, &gfx.queue, &vertices); } + +/// Добавляет крест из трёх осей — маркер точки захвата/заморозки. +fn push_cross( + lines: &mut Vec, + center: math::Vec3, + arm: f32, + kind: engine::DebugLineKind, +) { + for axis in [ + math::Vec3::new(arm, 0.0, 0.0), + math::Vec3::new(0.0, arm, 0.0), + math::Vec3::new(0.0, 0.0, arm), + ] { + lines.push(engine::DebugLine { + from: center - axis, + to: center + axis, + kind, + }); + } +} use tracing::{debug, info, warn}; /// Вьюмодель по умолчанию, когда SWEP не выбран (прежнее поведение). @@ -230,13 +254,18 @@ fn weapon_slot_for_key(code: Keycode) -> Option { } } -/// Модель для текущего состояния: override отладки важнее SWEP. +/// Модель для текущего состояния: override отладки важнее физгана и SWEP. fn desired_view_model( override_path: &Option>, swep: Option<&crate::weapon::SwepState>, + physgun_active: bool, ) -> Option { match override_path { Some(choice) => choice.clone(), + // В ассетах нет `v_physgun.mdl` (модель GMod), поэтому `set_model` + // откатится на пистолет/монтировку — лучше, чем оставить в руках + // модель предыдущего оружия. + None if physgun_active => Some("models/weapons/v_physgun.mdl".to_string()), None => swep .and_then(crate::weapon::SwepState::active_view_model) .map(str::to_string) @@ -574,6 +603,20 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } } + // Физган — отдельный режим (`F`), а не SWEP. По умолчанию он активен, + // если не выбран SWEP: это прежнее поведение ЛКМ. `SOURCE_RS_PHYSGUN=1` + // включает принудительно и заодно держит ближайший проп без мыши. + let test_physgun = std::env::var_os("SOURCE_RS_TEST_PHYSGUN").is_some(); + let test_undo = std::env::var_os("SOURCE_RS_TEST_UNDO").is_some(); + let test_redo = std::env::var_os("SOURCE_RS_TEST_REDO").is_some(); + let auto_grab = std::env::var_os("SOURCE_RS_PHYSGUN").is_some() || test_physgun; + let mut physgun_active = auto_grab || swep.as_ref().is_none_or(|state| state.active.is_none()); + // Авто-захват для отладки отключается после скриптовой заморозки, иначе + // физган тут же разморозил бы проп обратно. + let mut auto_grab_enabled = auto_grab; + // Undo-стек Sandbox: 32 последних действия. + let mut undo = UndoStack::new(sandbox::UNDO_LIMIT); + // Вьюмодель (оружие в руках, A23): `SOURCE_RS_VIEW_MODEL` — override для // отладки (`off` гасит, пустое значение — пистолет), иначе модель // активного SWEP, а при пустом слоте — прежний пистолет. @@ -583,12 +626,14 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { Ok(value) => Some(Some(value)), Err(_) => None, }; - let initial_view_model = desired_view_model(&view_model_override, swep.as_ref()); - // Откат на пистолет нужен только для отладочного override и пустого - // слота. У SWEP откат запрещён: `c_arms.mdl` — скелет без геометрии, и - // подменять кулаки пистолетом значит врать игроку. - let view_model_fallback = - view_model_override.is_some() || swep.as_ref().is_none_or(|state| state.active.is_none()); + let initial_view_model = + desired_view_model(&view_model_override, swep.as_ref(), physgun_active); + // Откат на пистолет нужен только для отладочного override, физгана и + // пустого слота. У SWEP откат запрещён: `c_arms.mdl` — скелет без + // геометрии, и подменять кулаки пистолетом значит врать игроку. + let view_model_fallback = view_model_override.is_some() + || physgun_active + || swep.as_ref().is_none_or(|state| state.active.is_none()); let mut view_model = ViewModelState::new( &world, &vfs, @@ -815,12 +860,8 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { // Время для процедурной пробы скиннинга вьюмодели (A26). let mut wave_time = 0.0f32; - // Физган: тело, которое держим, и расстояние удержания (A21/A23). - // Тело, расстояние удержания и углы: колесо мыши крутит взятый проп. - let mut grabbed: Option<(engine::physics::BodyId, f32, math::Vec3)> = None; - // `SOURCE_RS_PHYSGUN=1` — держать ближайшее тело без мыши: нужно, чтобы - // проверять физган кадром и скриптом, а не руками. - let auto_grab = std::env::var_os("SOURCE_RS_PHYSGUN").is_some(); + // Физган: удерживаемый проп, расстояние, цель поворота и поза захвата. + let mut grab: Option = None; // `SOURCE_RS_TEST_ATTACK=1` — нажать ЛКМ на 10-м кадре и отпустить на // 20-м: скриптовая проверка атаки SWEP без окна и мыши. let test_attack = std::env::var_os("SOURCE_RS_TEST_ATTACK").is_some(); @@ -838,6 +879,10 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { // Анимация модели игрока по состоянию (A26): тип и фаза. let mut player_animation = engine::AnimationState::default(); + // Переиспользуемые буферы отладочных линий: луч физгана и маркеры freeze + // не должны аллоцировать Vec каждый кадр. + let mut debug_extra: Vec = Vec::new(); + let mut frozen_markers: Vec = Vec::new(); 'running: loop { for event in events.poll_iter() { @@ -890,7 +935,11 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { ); // Меню — модальное: взятый физганом проп отпускаем, // иначе он останется висеть на луче взгляда. - grabbed = None; + if let Some(current) = grab.take() { + let message = + sandbox::release_grab(&mut world.borrow_mut(), current, &mut undo); + log.push_back(message); + } player.velocity = math::Vec3::ZERO; ui_touch_finger = None; log.push_back(if lua_ui_ready { @@ -964,6 +1013,40 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { pending_spawns .push(menu_spawn_request(&item, &collision, &player, camera.yaw)); } + } else if physgun_active { + // Физган: берём ближайший проп под прицелом — по + // расстоянию вдоль луча, с допуском на толщину пропа. + let origin = player.eye_position(); + let dir = camera.forward(); + let found = { + let w = world.borrow(); + sandbox::find_target( + &w, + origin, + dir, + sandbox::PHYSGUN_RANGE, + sandbox::PHYSGUN_MAX_MASS, + true, + ) + }; + match found { + Some((body, along)) => { + let created = sandbox::grab(&mut world.borrow_mut(), body, along); + if let Some(new_grab) = created { + log.push_back(format!( + "физган: взял ({} юнитов)", + along as i32 + )); + grab = Some(new_grab); + } + } + None => { + log.push_back("физган: нет цели".to_string()); + } + } + while log.len() > 8 { + log.pop_front(); + } } else { // Активный SWEP забирает ЛКМ себе: `PrimaryAttack` — // единственный путь, которым скрипт делает выстрел. @@ -985,45 +1068,6 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { while log.len() > 8 { log.pop_front(); } - } else { - // Физган: берём ближайшее тело под прицелом — по - // расстоянию вдоль луча, с допуском на толщину пропа. - let origin = player.eye_position(); - let dir = camera.forward(); - let w = world.borrow(); - let mut best: Option<(f32, engine::physics::BodyId)> = None; - for id in w.physics.body_list() { - let Some((position, _)) = w.physics.transform(id) else { - continue; - }; - let to_body = position - origin; - let along = to_body.dot(dir); - if !(8.0..=260.0).contains(&along) { - continue; - } - let perpendicular = (to_body - dir * along).length(); - if perpendicular > 40.0 { - continue; - } - if best.is_none_or(|(best_along, _)| along < best_along) { - best = Some((along, id)); - } - } - if let Some((along, id)) = best { - grabbed = Some((id, along, math::Vec3::ZERO)); - log.push_back(format!("физган: взял ({} юнитов)", along as i32)); - while log.len() > 8 { - log.pop_front(); - } - } else if view_model.fire.is_some() { - // Под прицелом нет тела — ЛКМ стреляет: одноразовая - // анимация вьюмодели, без оружия и урона (A23). - view_model.fire_time = Some(0.0); - log.push_back("выстрел".to_string()); - while log.len() > 8 { - log.pop_front(); - } - } } } } @@ -1035,8 +1079,12 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { state.primary_up(); } // В меню отпускание кнопки не относится к физгану. - if !spawn_menu.is_open() && grabbed.take().is_some() { - log.push_back("физган: отпустил".to_string()); + if !spawn_menu.is_open() + && let Some(current) = grab.take() + { + let message = + sandbox::release_grab(&mut world.borrow_mut(), current, &mut undo); + log.push_back(message); while log.len() > 8 { log.pop_front(); } @@ -1054,8 +1102,17 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { false }; if switched { - let model = desired_view_model(&view_model_override, swep.as_ref()); + // Выбор SWEP выключает физган: ЛКМ снова стреляет. + physgun_active = false; + if let Some(current) = grab.take() { + let message = + sandbox::release_grab(&mut world.borrow_mut(), current, &mut undo); + log.push_back(message); + } + let model = + desired_view_model(&view_model_override, swep.as_ref(), physgun_active); let fallback = view_model_override.is_some() + || physgun_active || swep.as_ref().is_none_or(|state| state.active.is_none()); view_model.set_model( &world, @@ -1083,6 +1140,120 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } } } + Event::MouseButtonDown { + mouse_btn: MouseButton::Right, + .. + } if !spawn_menu.is_open() && physgun_active => { + // ПКМ — заморозка: удерживаемый проп фиксируется и + // отпускается, свободный — замораживается/размораживается. + if let Some(current) = grab.take() { + let message = + sandbox::freeze_grabbed(&mut world.borrow_mut(), current, &mut undo); + log.push_back(message); + } else { + let origin = player.eye_position(); + let dir = camera.forward(); + let toggle = { + let mut w = world.borrow_mut(); + sandbox::toggle_freeze_at(&mut w, origin, dir, sandbox::PHYSGUN_RANGE) + }; + match toggle { + Some(toggle) => { + undo.push(SandboxAction::Freeze { + prop: toggle.prop, + before: toggle.before, + after: toggle.after, + }); + log.push_back( + if toggle.after { + "физган: заморожен" + } else { + "физган: разморожен" + } + .to_string(), + ); + } + None => log.push_back("физган: нет цели".to_string()), + } + } + while log.len() > 8 { + log.pop_front(); + } + } + Event::KeyDown { + keycode: Some(Keycode::F), + repeat: false, + .. + } => { + // F — отдельный режим физгана (не SWEP-слот: слоты 0-9 + // заняты оружием). Повторное нажатие возвращает оружие. + physgun_active = !physgun_active; + if !physgun_active { + if let Some(current) = grab.take() { + let message = + sandbox::release_grab(&mut world.borrow_mut(), current, &mut undo); + log.push_back(message); + } + if let Some(state) = swep.as_mut() { + state.primary_up(); + } + } + let model = + desired_view_model(&view_model_override, swep.as_ref(), physgun_active); + let fallback = view_model_override.is_some() + || physgun_active + || swep.as_ref().is_none_or(|state| state.active.is_none()); + view_model.set_model( + &world, + &vfs, + model.as_deref(), + player.eye_position(), + fallback, + ); + log.push_back(if physgun_active { + "физган: включён (ЛКМ, ПКМ freeze, колесо)".to_string() + } else { + "физган: выключен".to_string() + }); + while log.len() > 8 { + log.pop_front(); + } + } + Event::KeyDown { + keycode: Some(Keycode::Z), + repeat: false, + .. + } => { + // Z — undo последнего действия Sandbox, Shift+Z — redo. + // Удерживаемый проп сначала отпускаем без записи undo, + // иначе откат начнёт с собственного захвата. + let redo = + pressed.contains(&Keycode::LShift) || pressed.contains(&Keycode::RShift); + if let Some(current) = grab.take() { + sandbox::cancel_grab(&mut world.borrow_mut(), current); + } + let message = { + let mut w = world.borrow_mut(); + if redo { + undo.redo(&mut w) + } else { + undo.undo(&mut w) + } + }; + if let Some(message) = message { + info!(redo, depth = undo.depth(), "Sandbox undo"); + log.push_back(message); + } else { + log.push_back(if redo { + "redo: пусто".to_string() + } else { + "undo: пусто".to_string() + }); + } + while log.len() > 8 { + log.pop_front(); + } + } Event::KeyDown { keycode: Some(Keycode::G), repeat: false, @@ -1154,10 +1325,12 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } else if spawn_menu.is_open() { // Колесо листает страницы списка (он длинный). spawn_menu.scroll(if y > 0.0 { -1 } else { 1 }); - } else if let Some((id, along, angles)) = grabbed { - // Иначе колесо крутит взятый проп вокруг вертикальной оси. - let angles = math::Vec3::new(angles.x, angles.y + y * 15.0, angles.z); - grabbed = Some((id, along, angles)); + } else if let Some(current) = grab.as_mut() { + // Иначе колесо крутит взятый проп вокруг вертикальной + // оси: цель поворота доворачивается, а физика плавно + // догоняет её угловой скоростью. Держим угол в + // [0, 360), чтобы длинное вращение не теряло точность. + current.target_yaw = (current.target_yaw + y * 15.0).rem_euclid(360.0); } } Event::MouseMotion { x, y, .. } if lua_ui_open => { @@ -1195,7 +1368,14 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { relative_mouse, ); if open { - grabbed = None; + if let Some(current) = grab.take() { + let message = sandbox::release_grab( + &mut world.borrow_mut(), + current, + &mut undo, + ); + log.push_back(message); + } player.velocity = math::Vec3::ZERO; } ui_touch_finger = None; @@ -1362,6 +1542,13 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { "проп заспавнен" ); log.push_back(format!("spawn: {}", request.model)); + // Undo спавна: удалит тело/сущность и спрячет меш. + undo.push(SandboxAction::Spawn { + prop: index, + mass: 10.0, + origin: request.origin, + angles: request.angles, + }); // Геометрия нового пропа уходит на GPU диапазонами // (`moved_ranges`), поэтому пересобирать рендерер нужно // только если появилась НОВАЯ текстура: иначе батч пропа @@ -1441,8 +1628,9 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { false }; if switched { - let model = desired_view_model(&view_model_override, swep.as_ref()); + let model = desired_view_model(&view_model_override, swep.as_ref(), physgun_active); let fallback = view_model_override.is_some() + || physgun_active || swep.as_ref().is_none_or(|state| state.active.is_none()); view_model.set_model( &world, @@ -1461,6 +1649,64 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } } + // Скриптовые проверки Sandbox без мыши: тестовый проп, удержание и undo. + if (test_undo || test_physgun || test_redo) && frames_drawn == 5 { + let model = std::env::var("SOURCE_RS_SPAWN_MODEL") + .unwrap_or_else(|_| "models/props_junk/watermelon01.mdl".to_string()); + let origin = player.eye_position() + camera.forward() * 140.0; + let angles = math::Vec3::new(0.0, camera.yaw, 0.0); + info!(model = %model, "Sandbox тест: спавн тестового пропа"); + pending_spawns.push(SpawnRequest { + model, + origin, + angles, + }); + } + if test_physgun && frames_drawn == 25 { + if let Some(current) = &mut grab { + // Колесо: доворот на 25° — проверка, что поворот идёт + // угловой скоростью, а не телепортом позы. + current.target_yaw += 25.0; + info!(body = ?current.body, "Sandbox тест: физган держит, поворот +25°"); + } else { + warn!("Sandbox тест: физган не захватил проп"); + } + } + if test_physgun && frames_drawn == 40 { + if let Some(current) = grab.take() { + let message = sandbox::freeze_grabbed(&mut world.borrow_mut(), current, &mut undo); + // Авто-захват иначе сразу разморозил бы проп обратно. + auto_grab_enabled = false; + info!(message = %message, "Sandbox тест: freeze по ПКМ"); + log.push_back(message); + } + } + if test_undo && frames_drawn == 30 { + let alive = world.borrow().alive_prop_count(); + info!(alive, "Sandbox тест: undo до отката"); + } + if test_undo && frames_drawn == 35 { + if let Some(current) = grab.take() { + sandbox::cancel_grab(&mut world.borrow_mut(), current); + } + let message = undo.undo(&mut world.borrow_mut()); + let alive = world.borrow().alive_prop_count(); + info!(?message, alive, "Sandbox тест: undo после отката"); + if let Some(message) = message { + log.push_back(message); + } + } + if test_redo && frames_drawn == 20 { + let message = undo.undo(&mut world.borrow_mut()); + let alive = world.borrow().alive_prop_count(); + info!(?message, alive, "Sandbox тест: redo — undo спавна"); + } + if test_redo && frames_drawn == 30 { + let message = undo.redo(&mut world.borrow_mut()); + let alive = world.borrow().alive_prop_count(); + info!(?message, alive, "Sandbox тест: redo — проп возвращён"); + } + // Физика идёт до ходьбы игрока: игрок должен упираться в уже // сдвинутые пропы, а не в прошлые. wave_time += dt; @@ -1468,43 +1714,40 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { let moved_index_ranges: Vec<(u32, u32)>; { let mut w = world.borrow_mut(); - w.step_physics(dt); - w.recover_fallen_props(-8192.0); - // Физган: держим тело на луче взгляда, на взятом расстоянии. - if auto_grab && grabbed.is_none() && !spawn_menu.is_open() && !lua_ui_open { + // Физган: `SOURCE_RS_PHYSGUN=1` держит ближайший проп без мыши — + // проверка кадром. Обычный захват уже сделан по ЛКМ. + if auto_grab_enabled + && physgun_active + && grab.is_none() + && !spawn_menu.is_open() + && !lua_ui_open + { let origin = player.eye_position(); let dir = camera.forward(); - let mut best: Option<(f32, engine::physics::BodyId)> = None; - for id in w.physics.body_list() { - let Some((position, _)) = w.physics.transform(id) else { - continue; - }; - let to_body = position - origin; - let along = to_body.dot(dir); - if !(8.0..=320.0).contains(&along) { - continue; - } - if (to_body - dir * along).length() > 64.0 { - continue; - } - if best.is_none_or(|(best_along, _)| along < best_along) { - best = Some((along, id)); - } - } - if let Some((along, id)) = best { - grabbed = Some((id, along, math::Vec3::ZERO)); + if let Some((body, along)) = sandbox::find_target( + &w, + origin, + dir, + sandbox::PHYSGUN_RANGE, + sandbox::PHYSGUN_MAX_MASS, + false, + ) { + grab = sandbox::grab(&mut w, body, along); } } - if let Some((id, along, mut angles)) = grabbed { - // Углы при захвате берём у тела, дальше их крутит колесо. - if angles == math::Vec3::ZERO - && let Some((_, current)) = w.physics.transform(id) - { - angles = current; - } - let target = player.eye_position() + camera.forward() * along; - w.physics.teleport(id, target, angles); - grabbed = Some((id, along, angles)); + // Скорость к точке на луче задаётся ДО шага физики: проп едет + // плавно и сталкивается с миром, а не телепортируется. + if let Some(current) = &grab { + sandbox::hold(&mut w, current, player.eye_position(), camera.forward(), dt); + } + w.step_physics(dt); + w.recover_fallen_props(-8192.0); + // Тело могло исчезнуть (undo спавна) — захват больше не нужен. + if grab + .as_ref() + .is_some_and(|current| w.physics.transform(current.body).is_none()) + { + grab = None; } } // Габариты пропов уходят в коллизию игрока: без этого он ходит по @@ -1891,7 +2134,23 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } ), ); - if let Some(state) = &swep { + if physgun_active { + let held = grab + .as_ref() + .map(|current| format!("HOLD {:.0}u", current.distance)) + .unwrap_or_else(|| "ready".to_string()); + hud( + 8.0, + hud_top + 40.0, + 1.4, + [0.6, 0.9, 1.0, 1.0], + format!( + "physgun [F] {held} undo {}/{}", + undo.depth(), + undo.redo_depth() + ), + ); + } else if let Some(state) = &swep { let text = if let Some(weapon) = state.active_info() { let mut text = format!("weapon: {}", weapon.display_name()); if let Some(lua) = &lua @@ -1908,18 +2167,32 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { hud(8.0, hud_top + 40.0, 1.4, [1.0, 0.9, 0.65, 1.0], text); } if !menu_open { - hud( - 8.0, - hud_bottom, - 1.5, - [0.95, 0.95, 0.8, 1.0], - if touch_enabled { - "touch: left stick move | right swipe look | MENU/jump/duck".to_string() - } else { - "WASD move | Space jump | Ctrl duck | V noclip | G spawn | Q menu | 0-9 weapon | ` log" - .to_string() - }, - ); + if touch_enabled { + hud( + 8.0, + hud_bottom, + 1.5, + [0.95, 0.95, 0.8, 1.0], + "touch: left stick move | right swipe look | MENU/jump/duck".to_string(), + ); + } else { + // Две строки: подсказка Sandbox длиннее одной строки HUD. + hud( + 8.0, + hud_bottom - 16.0, + 1.3, + [0.95, 0.95, 0.8, 1.0], + "WASD move | Space jump | Ctrl duck | V noclip | F physgun | LMB grab | RMB freeze | wheel rotate" + .to_string(), + ); + hud( + 8.0, + hud_bottom, + 1.3, + [0.95, 0.95, 0.8, 1.0], + "G spawn | Q menu | Z undo | Shift+Z redo | 0-9 weapon | ` log".to_string(), + ); + } } if touch_enabled { // Подписи зон: сами зоны заданы в `touch.rs`, здесь только @@ -1955,7 +2228,7 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { ); } if show_log { - let mut y = hud_bottom - 32.0; + let mut y = hud_bottom - 48.0; for line in log.iter().rev().take(4) { hud(8.0, y, 1.2, [0.8, 1.0, 0.8, 0.95], line.clone()); y -= 16.0; @@ -2040,17 +2313,68 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { ); } - // Отладочный слой коллизий вокруг игрока: истинные кирпичи и их - // габариты. Включается `SOURCE_RS_DEBUG_COLLISION`. + // Отладочный слой коллизий вокруг игрока и луч физгана: истинные + // кирпичи и их габариты включаются `SOURCE_RS_DEBUG_COLLISION`, луч + // рисуется всегда, когда проп удерживается. { let body = player.aabb(); - upload_debug_collision( + debug_extra.clear(); + // Замороженные пропы: жёлтый крест, иначе freeze не отличить от + // просто неподвижного пропа на скриншоте. + { + let w = world.borrow(); + w.physics.frozen_positions_into(&mut frozen_markers, 32); + } + for target in &frozen_markers { + push_cross( + &mut debug_extra, + *target, + 6.0, + engine::DebugLineKind::Frozen, + ); + } + if let Some(current) = &grab { + let target = world + .borrow() + .physics + .transform(current.body) + .map(|(position, _)| position); + if let Some(target) = target { + let (sin_yaw, cos_yaw) = camera.yaw.to_radians().sin_cos(); + let right = math::Vec3::new(sin_yaw, -cos_yaw, 0.0); + let up = math::Vec3::new(0.0, 0.0, 1.0); + // «Дуло» смещено вправо-вниз, как вьюмодель: луч из центра + // глаз при взгляде прямо на проп проецируется в точку и + // становится невидимым. + let muzzle = + player.eye_position() + camera.forward() * 26.0 + right * 9.0 - up * 7.0; + // Пучок из пяти линий: LineList в wgpu всегда один пиксель, + // а одиночный луч на зелёной траве почти не виден. + for (dx, dy) in [(0.0, 0.0), (1.5, 0.0), (-1.5, 0.0), (0.0, 1.5), (0.0, -1.5)] { + let offset = right * dx + up * dy; + debug_extra.push(engine::DebugLine { + from: muzzle + offset, + to: target + offset, + kind: engine::DebugLineKind::Physgun, + }); + } + // Крест в точке захвата: видно, что именно держим. + push_cross( + &mut debug_extra, + target, + 6.0, + engine::DebugLineKind::Physgun, + ); + } + } + upload_debug_layer( &mut renderer, &gfx, &collision, player.position, debug_collision, Some(&body), + &debug_extra, ); } @@ -2174,13 +2498,14 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { let yaw = from.2 + (to.2 - from.2) * smooth; let mut fly = FlyCamera::new(position, yaw, pitch); fly.fov = args.fov; - upload_debug_collision( + upload_debug_layer( &mut renderer, &gfx, &collision, position, debug_collision, None, + &[], ); let (width, height) = gfx.size(); let pixels = renderer.capture(&gfx, &fly, width, height)?; diff --git a/crates/client/src/sandbox.rs b/crates/client/src/sandbox.rs new file mode 100644 index 0000000..a160511 --- /dev/null +++ b/crates/client/src/sandbox.rs @@ -0,0 +1,504 @@ +//! Sandbox-инструменты клиента: физган и undo-стек. +//! +//! Физган — отдельный режим (клавиша `F`), а не SWEP: в GMod `weapon_physgun` +//! движковое, но поведение то же — луч из глаз, удержание пропа на расстоянии, +//! плавное движение скоростью (не телепорт), поворот колесом и заморозка ПКМ. +//! +//! Undo живёт поверх операций мира: спавн (`World::remove_prop` / +//! `World::restore_prop`), перемещение (`PhysicsWorld::teleport` + +//! `World::move_prop`) и заморозка (`PhysicsWorld::set_frozen`). Геометрия +//! удалённого пропа не вырезается из буферов, а уводится под карту — см. +//! `World::remove_prop`. + +use engine::physics::BodyId; +use engine::world::World; +use math::Vec3; +use tracing::info; + +/// Дальность физгана в юнитах Source (в GMod порядка 250). +pub const PHYSGUN_RANGE: f32 = 250.0; + +/// Предел массы, которую берёт физган. +pub const PHYSGUN_MAX_MASS: f32 = 500.0; + +/// Минимальное расстояние вдоль луча: проп «в упоре» не хватаем. +const GRAB_MIN_ALONG: f32 = 8.0; + +/// Допуск по перпендикуляру к лучу: тело считается под прицелом. +const GRAB_TOLERANCE: f32 = 40.0; + +/// Жёсткость удержания: скорость пропорциональна ошибке позиции. +const HOLD_STIFFNESS: f32 = 12.0; + +/// Максимальная скорость удержания, юнитов/с. +const HOLD_MAX_SPEED: f32 = 900.0; + +/// Жёсткость поворота колесом. +const ROTATE_STIFFNESS: f32 = 10.0; + +/// Максимальная угловая скорость удержания, градусов/с. +const ROTATE_MAX_SPEED: f32 = 720.0; + +/// Порог, после которого отпускание считается перемещением для undo. +const MOVE_EPSILON: f32 = 0.5; + +/// Порог поворота для undo, градусы. +const ANGLE_EPSILON: f32 = 0.5; + +/// Сколько действий помнит undo-стек. +pub const UNDO_LIMIT: usize = 32; + +/// Поза пропа: позиция и углы Source. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Pose { + pub origin: Vec3, + pub angles: Vec3, +} + +impl Pose { + pub fn new(origin: Vec3, angles: Vec3) -> Self { + Self { origin, angles } + } + + /// Заметно ли отличается поза (для записи undo при отпускании). + pub fn differs(&self, other: &Pose) -> bool { + (self.origin - other.origin).length() > MOVE_EPSILON + || wrap_degrees(self.angles.x - other.angles.x).abs() > ANGLE_EPSILON + || wrap_degrees(self.angles.y - other.angles.y).abs() > ANGLE_EPSILON + || wrap_degrees(self.angles.z - other.angles.z).abs() > ANGLE_EPSILON + } +} + +/// Одно действие Sandbox, которое умеет откатывать undo. +#[derive(Debug, Clone)] +pub enum SandboxAction { + /// Спавн пропа: undo удаляет, redo возвращает на место. + Spawn { + prop: usize, + mass: f32, + origin: Vec3, + angles: Vec3, + }, + /// Перемещение/поворот физганом: undo возвращает позу `before`. + Move { + prop: usize, + before: Pose, + after: Pose, + }, + /// Заморозка: undo возвращает прежнее состояние. + Freeze { + prop: usize, + before: bool, + after: bool, + }, +} + +impl SandboxAction { + pub fn label(&self) -> &'static str { + match self { + Self::Spawn { .. } => "спавн", + Self::Move { .. } => "перемещение", + Self::Freeze { .. } => "заморозка", + } + } +} + +/// Стек действий Sandbox: `Z` — undo, `Shift+Z` — redo. +pub struct UndoStack { + undo: Vec, + redo: Vec, + limit: usize, +} + +impl UndoStack { + pub fn new(limit: usize) -> Self { + Self { + undo: Vec::new(), + redo: Vec::new(), + limit: limit.max(1), + } + } + + /// Записывает действие. Новое действие обнуляет redo, как в редакторах. + pub fn push(&mut self, action: SandboxAction) { + self.redo.clear(); + self.undo.push(action); + while self.undo.len() > self.limit { + self.undo.remove(0); + } + } + + /// Откатывает последнее действие. Возвращает строку для HUD-лога. + pub fn undo(&mut self, world: &mut World) -> Option { + let action = self.undo.pop()?; + let label = action.label(); + if apply(world, &action, false) { + self.redo.push(action); + Some(format!("undo: {label}")) + } else { + // Проп уже удалён/недоступен — действие снимаем, чтобы Z не + // застревал на нём навсегда. + Some(format!("undo: {label} — проп недоступен")) + } + } + + /// Повторяет отменённое действие. + pub fn redo(&mut self, world: &mut World) -> Option { + let action = self.redo.pop()?; + let label = action.label(); + if apply(world, &action, true) { + self.undo.push(action); + Some(format!("redo: {label}")) + } else { + Some(format!("redo: {label} — проп недоступен")) + } + } + + pub fn depth(&self) -> usize { + self.undo.len() + } + + pub fn redo_depth(&self) -> usize { + self.redo.len() + } +} + +/// Применяет действие вперёд (redo) или назад (undo). +fn apply(world: &mut World, action: &SandboxAction, forward: bool) -> bool { + match action { + SandboxAction::Spawn { + prop, + mass, + origin, + angles, + } => { + if forward { + if !world.restore_prop(*prop, *mass) { + return false; + } + // Поза из действия важнее текущей: после undo перемещения она + // уже совпадает, но при потерянном действии — нет. + if let Some(body) = world.prop(*prop).and_then(|prop| prop.physics_body) { + world.physics.teleport(body, *origin, *angles); + } + let _ = world.move_prop(*prop, *origin, *angles); + true + } else { + world.remove_prop(*prop) + } + } + SandboxAction::Move { + prop, + before, + after, + } => { + let Some(body) = world + .prop(*prop) + .filter(|prop| !prop.removed) + .and_then(|prop| prop.physics_body) + else { + return false; + }; + let pose = if forward { after } else { before }; + // Тело может быть заморожено — teleport работает и для fixed. + if !world.physics.teleport(body, pose.origin, pose.angles) { + return false; + } + let _ = world.move_prop(*prop, pose.origin, pose.angles); + true + } + SandboxAction::Freeze { + prop, + before, + after, + } => { + let Some(body) = world + .prop(*prop) + .filter(|prop| !prop.removed) + .and_then(|prop| prop.physics_body) + else { + return false; + }; + let frozen = if forward { *after } else { *before }; + world.physics.set_frozen(body, frozen) + } + } +} + +/// Удерживаемый физганом проп. +pub struct Grab { + pub body: BodyId, + /// Индекс в `World::props` — для undo и заморозки. + pub prop: usize, + /// Расстояние удержания от глаз вдоль луча. + pub distance: f32, + /// Куда довернуть проп колесом (yaw, градусы). + pub target_yaw: f32, + /// Поза на момент захвата — для undo перемещения. + pub start: Pose, + /// Проп был заморожен: на отпускании вернём freeze. + pub frozen_before: bool, +} + +/// Находит динамический проп под лучом: ближайший по расстоянию вдоль луча. +/// +/// Статика карты, игрок и слишком тяжёлые тела отсеиваются. Это не трассировка +/// с окклюзией, а та же выборка по оси, что была в клиенте: для физгана она +/// предсказуемее и дешевле. +pub fn find_target( + world: &World, + origin: Vec3, + direction: Vec3, + max_distance: f32, + max_mass: f32, + include_frozen: bool, +) -> Option<(BodyId, f32)> { + if !direction.is_finite() || direction.length_squared() < 1e-6 { + return None; + } + let dir = direction.normalized(); + let mut best: Option<(f32, BodyId)> = None; + for id in world.physics.body_list() { + if !world.physics.is_dynamic(id) { + continue; + } + if !include_frozen && world.physics.is_frozen(id) { + continue; + } + if world.physics.mass(id).is_none_or(|mass| mass > max_mass) { + continue; + } + // Берём только пропы: у игрока и служебных тел индекса в `props` нет. + if prop_for_body(world, id).is_none() { + continue; + } + let Some((position, _)) = world.physics.transform(id) else { + continue; + }; + let to_body = position - origin; + let along = to_body.dot(dir); + if !(GRAB_MIN_ALONG..=max_distance).contains(&along) { + continue; + } + if (to_body - dir * along).length() > GRAB_TOLERANCE { + continue; + } + if best.is_none_or(|(best_along, _)| along < best_along) { + best = Some((along, id)); + } + } + best.map(|(along, id)| (id, along)) +} + +/// Захватывает тело: снимает freeze на время удержания и гасит гравитацию. +pub fn grab(world: &mut World, body: BodyId, along: f32) -> Option { + let prop = prop_for_body(world, body)?; + let (origin, angles) = world.physics.transform(body)?; + let frozen_before = world.physics.is_frozen(body); + if frozen_before && !world.physics.set_frozen(body, false) { + return None; + } + world.physics.set_gravity_scale(body, 0.0); + world.physics.set_velocity(body, Vec3::ZERO); + world.physics.set_angular_velocity(body, Vec3::ZERO); + info!(?body, prop, along, "физган: захват"); + Some(Grab { + body, + prop, + distance: along.max(GRAB_MIN_ALONG), + target_yaw: angles.y, + start: Pose::new(origin, angles), + frozen_before, + }) +} + +/// Один кадр удержания: скорость к точке на луче и доворот к `target_yaw`. +/// +/// Скорость считается экспоненциальным приближением к цели, поэтому проп не +/// телепортируется и не проскакивает: чем ближе, тем медленнее. Гравитация на +/// время удержания выключена в `grab`. +pub fn hold(world: &mut World, grab: &Grab, origin: Vec3, direction: Vec3, dt: f32) { + let Some((position, angles)) = world.physics.transform(grab.body) else { + return; + }; + if !direction.is_finite() || direction.length_squared() < 1e-6 { + return; + } + let dir = direction.normalized(); + let target = origin + dir * grab.distance; + let delta = target - position; + let dt = dt.clamp(1.0 / 240.0, 0.1); + + let blend = 1.0 - (-dt * HOLD_STIFFNESS).exp(); + let mut velocity = delta * (blend / dt); + let speed = velocity.length(); + if speed > HOLD_MAX_SPEED { + velocity = velocity * (HOLD_MAX_SPEED / speed); + } + world.physics.set_velocity(grab.body, velocity); + + let diff = wrap_degrees(grab.target_yaw - angles.y); + let blend = 1.0 - (-dt * ROTATE_STIFFNESS).exp(); + let angular = (diff * (blend / dt)).clamp(-ROTATE_MAX_SPEED, ROTATE_MAX_SPEED); + world + .physics + .set_angular_velocity(grab.body, Vec3::new(0.0, 0.0, angular.to_radians())); +} + +/// Отпускает проп по ЛКМ: возвращает гравитацию/freeze и пишет undo, если +/// поза действительно изменилась. +pub fn release_grab(world: &mut World, grab: Grab, undo: &mut UndoStack) -> String { + let after = world + .physics + .transform(grab.body) + .map(|(origin, angles)| Pose::new(origin, angles)); + world.physics.set_gravity_scale(grab.body, 1.0); + if grab.frozen_before { + world.physics.set_frozen(grab.body, true); + } + if let Some(after) = after + && grab.start.differs(&after) + { + undo.push(SandboxAction::Move { + prop: grab.prop, + before: grab.start, + after, + }); + } + info!( + prop = grab.prop, + frozen = grab.frozen_before, + "физган: отпускание" + ); + if grab.frozen_before { + "физган: отпустил (freeze сохранён)".to_string() + } else { + "физган: отпустил".to_string() + } +} + +/// Отпускает без записи undo (меню, повторный захват, undo-клавиша). +pub fn cancel_grab(world: &mut World, grab: Grab) { + world.physics.set_gravity_scale(grab.body, 1.0); + if grab.frozen_before { + world.physics.set_frozen(grab.body, true); + } +} + +/// ПКМ по удерживаемому пропу: фиксирует позу, замораживает и отпускает. +pub fn freeze_grabbed(world: &mut World, grab: Grab, undo: &mut UndoStack) -> String { + let after = world + .physics + .transform(grab.body) + .map(|(origin, angles)| Pose::new(origin, angles)); + if let Some(after) = after + && grab.start.differs(&after) + { + undo.push(SandboxAction::Move { + prop: grab.prop, + before: grab.start, + after, + }); + } + world.physics.set_gravity_scale(grab.body, 1.0); + let frozen = world.physics.set_frozen(grab.body, true); + if frozen && !grab.frozen_before { + undo.push(SandboxAction::Freeze { + prop: grab.prop, + before: false, + after: true, + }); + } + info!(prop = grab.prop, frozen, "физган: freeze удержанием"); + if frozen { + "физган: заморожен".to_string() + } else { + "физган: freeze не удался".to_string() + } +} + +/// Результат переключения заморозки по ПКМ (без удержания). +pub struct FreezeToggle { + pub prop: usize, + pub before: bool, + pub after: bool, +} + +/// ПКМ без захвата: замораживает/размораживает проп под прицелом. +pub fn toggle_freeze_at( + world: &mut World, + origin: Vec3, + direction: Vec3, + max_distance: f32, +) -> Option { + let (body, _) = find_target(world, origin, direction, max_distance, f32::INFINITY, true)?; + let prop = prop_for_body(world, body)?; + let before = world.physics.is_frozen(body); + let after = !before; + if !world.physics.set_frozen(body, after) { + return None; + } + info!(prop, before, after, "физган: freeze по прицелу"); + Some(FreezeToggle { + prop, + before, + after, + }) +} + +/// Индекс живого пропа, связанного с телом. +fn prop_for_body(world: &World, body: BodyId) -> Option { + let entity = world.physics.entity(body)?; + let index = world.prop_index_of_entity(entity)?; + (!world.prop(index)?.removed).then_some(index) +} + +/// Приводит угол к диапазону (−180, 180]. +fn wrap_degrees(angle: f32) -> f32 { + if !angle.is_finite() { + return 0.0; + } + (angle + 180.0).rem_euclid(360.0) - 180.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn action(prop: usize) -> SandboxAction { + SandboxAction::Spawn { + prop, + mass: 10.0, + origin: Vec3::ZERO, + angles: Vec3::ZERO, + } + } + + #[test] + fn undo_stack_is_bounded_and_keeps_newest() { + let mut stack = UndoStack::new(3); + for prop in 0..5 { + stack.push(action(prop)); + } + assert_eq!(stack.depth(), 3, "стек ограничен тремя действиями"); + // Новое действие обнуляет redo. + stack.push(action(99)); + assert_eq!(stack.redo_depth(), 0); + } + + #[test] + fn pose_difference_ignores_tiny_jitter_and_wraps_angles() { + let a = Pose::new(Vec3::ZERO, Vec3::new(0.0, 179.0, 0.0)); + let b = Pose::new(Vec3::new(0.1, 0.0, 0.0), Vec3::new(0.0, -179.0, 0.0)); + assert!(a.differs(&b), "поворот на 2° через 180° — изменение"); + let c = Pose::new(Vec3::new(0.1, 0.0, 0.0), Vec3::new(0.0, 179.2, 0.0)); + assert!(!a.differs(&c), "дрожание ниже порога не пишем в undo"); + } + + #[test] + fn wrap_degrees_is_finite_for_garbage() { + assert_eq!(wrap_degrees(f32::NAN), 0.0); + assert!((wrap_degrees(370.0) - 10.0).abs() < 1e-3); + assert!((wrap_degrees(-190.0) - 170.0).abs() < 1e-3); + } +} diff --git a/crates/engine/src/physics.rs b/crates/engine/src/physics.rs index 883d6f2..936dd51 100644 --- a/crates/engine/src/physics.rs +++ b/crates/engine/src/physics.rs @@ -145,6 +145,12 @@ pub struct PhysicsWorld { pub steps: u64, /// Соединения между телами (шарниры, верёвки, сварки). pub(crate) constraints: Vec, + /// Массы динамических тел на момент создания. + /// + /// `RigidBody::mass()` у замороженного (`fixed`) тела может обнулиться, а + /// физгану нужно ограничение массы и после заморозки. Поэтому храним + /// исходную массу отдельно от Rapier. + masses: HashMap, /// Сколько статических коллайдеров построено (рельеф, пол). /// /// Отдельно от `len()`, который считает только наши динамические тела: @@ -177,6 +183,7 @@ impl PhysicsWorld { world, handles: Vec::new(), constraints: Vec::new(), + masses: HashMap::new(), steps: 0, static_bodies: 0, } @@ -408,6 +415,7 @@ impl PhysicsWorld { .restitution(0.1) .build(); self.world.insert_collider(collider, Some(handle)); + self.masses.insert(handle, mass.max(0.1)); self.handles.push((handle, entity, Kind::Dynamic)); Ok(BodyId { inner: handle, @@ -453,6 +461,7 @@ impl PhysicsWorld { .rotation(quat_from_angles(angles_deg).to_scaled_axis()); let handle = self.world.insert_body(desc); self.world.insert_collider(collider, Some(handle)); + self.masses.insert(handle, mass.max(0.1)); self.handles.push((handle, entity, Kind::Dynamic)); Ok(BodyId { inner: handle, @@ -531,9 +540,15 @@ impl PhysicsWorld { } /// Удаляет тело вместе с его коллайдерами. + /// + /// Устаревшая ручка (тело уже удалили) — не ошибка: клиент обязан + /// переживать повторный undo, а не падать на нём. pub fn remove(&mut self, id: BodyId) -> Result<(), PhysicsError> { - self.world.remove_body_with_colliders(id.inner, true); + if self.world.bodies.get(id.inner).is_some() { + self.world.remove_body_with_colliders(id.inner, true); + } self.handles.retain(|(h, _, _)| *h != id.inner); + self.masses.remove(&id.inner); Ok(()) } @@ -586,6 +601,119 @@ impl PhysicsWorld { Some(Vec3::new(v.x, v.y, v.z)) } + /// Масса тела на момент создания. + /// + /// У замороженного тела Rapier может вернуть 0, поэтому исходная масса + /// хранится отдельно: иначе после freeze физган «не видел» бы тяжёлый + /// проп и ограничение массы зависело бы от порядка действий. + pub fn mass(&self, id: BodyId) -> Option { + if let Some(mass) = self.masses.get(&id.inner) { + return Some(*mass); + } + let body = self.world.bodies.get(id.inner)?; + let mass = body.mass(); + (mass.is_finite() && mass > 0.0).then_some(mass) + } + + /// Тело динамическое (проп), а не статика карты или игрок. + pub fn is_dynamic(&self, id: BodyId) -> bool { + id.kind == Kind::Dynamic && self.world.bodies.get(id.inner).is_some() + } + + /// Задаёт линейную скорость (юниты/с). + pub fn set_velocity(&mut self, id: BodyId, velocity: Vec3) -> bool { + if !velocity.is_finite() { + return false; + } + let Some(body) = self.world.bodies.get_mut(id.inner) else { + return false; + }; + body.set_linvel(Vector::new(velocity.x, velocity.y, velocity.z), true); + true + } + + /// Задаёт угловую скорость (радианы/с, мировые оси). + pub fn set_angular_velocity(&mut self, id: BodyId, velocity: Vec3) -> bool { + if !velocity.is_finite() { + return false; + } + let Some(body) = self.world.bodies.get_mut(id.inner) else { + return false; + }; + body.set_angvel(Vector::new(velocity.x, velocity.y, velocity.z), true); + true + } + + /// Множитель гравитации тела (0 — невесомое, пока физган держит). + pub fn set_gravity_scale(&mut self, id: BodyId, scale: f32) -> bool { + if !scale.is_finite() { + return false; + } + let Some(body) = self.world.bodies.get_mut(id.inner) else { + return false; + }; + body.set_gravity_scale(scale.clamp(0.0, 10.0), true); + true + } + + /// Заморозить/разморозить динамическое тело (аналог `PhysgunFreeze`). + /// + /// Замороженное тело переводится в `fixed`: гравитация и импульсы его не + /// двигают, но позу всё ещё можно менять `teleport`. Статика карты и игрок + /// не трогаются. + pub fn set_frozen(&mut self, id: BodyId, frozen: bool) -> bool { + if !self.is_dynamic(id) { + return false; + } + let Some(body) = self.world.bodies.get_mut(id.inner) else { + return false; + }; + if frozen { + body.set_linvel(Vector::ZERO, false); + body.set_angvel(Vector::ZERO, false); + body.set_body_type(RigidBodyType::Fixed, true); + } else { + body.set_body_type(RigidBodyType::Dynamic, true); + body.set_gravity_scale(1.0, true); + } + true + } + + /// Тело заморожено физганом. + pub fn is_frozen(&self, id: BodyId) -> bool { + if id.kind != Kind::Dynamic { + return false; + } + self.world + .bodies + .get(id.inner) + .is_some_and(|body| body.body_type() == RigidBodyType::Fixed) + } + + /// Собирает позиции замороженных тел в `out` (не больше `limit`). + /// + /// Буфер переиспользуется клиентом каждый кадр: маркеры freeze не должны + /// аллоцировать Vec в кадровом цикле. + pub fn frozen_positions_into(&self, out: &mut Vec, limit: usize) { + out.clear(); + for (handle, _, kind) in &self.handles { + if *kind != Kind::Dynamic { + continue; + } + let Some(body) = self.world.bodies.get(*handle) else { + continue; + }; + if body.body_type() != RigidBodyType::Fixed { + continue; + } + let t = body.translation(); + out.push(Vec3::new(t.x, t.y, t.z)); + if out.len() >= limit { + break; + } + } + } + /// Тело спит — физика его больше не трогает. pub fn is_sleeping(&self, id: BodyId) -> bool { self.world @@ -638,10 +766,13 @@ impl PhysicsWorld { .filter(|(_, _, k)| *k == Kind::Dynamic) .filter_map(|(h, _, _)| { let body = self.world.bodies.get(*h)?; - (body.translation().z < min_z).then_some(BodyId { - inner: *h, - kind: Kind::Dynamic, - }) + // Замороженное тело не «упало»: его удерживает freeze, и + // возвращать его на исходное место нельзя. + (body.body_type() == RigidBodyType::Dynamic && body.translation().z < min_z) + .then_some(BodyId { + inner: *h, + kind: Kind::Dynamic, + }) }) .collect() } @@ -857,6 +988,99 @@ mod tests { assert!(speed < 30.0, "проп должен успокоиться, скорость={}", speed); } + /// Замороженный проп висит в воздухе, размороженный — снова падает. + /// + /// Это проверка `PhysgunFreeze`: тело переводится в `fixed`, гравитация + /// его не тянет; снятие заморозки возвращает динамику. + #[test] + fn freeze_stops_falling_and_unfreeze_resumes() { + let mut physics = PhysicsWorld::new(); + physics.add_floor(0.0); + physics + .add_prop( + EntityIndex::INVALID, + Vec3::new(0.0, 0.0, 200.0), + Vec3::ZERO, + Vec3::new(16.0, 16.0, 16.0), + 10.0, + ) + .expect("тело"); + let id = physics.body_of(EntityIndex::INVALID).expect("нашли тело"); + assert!(physics.set_frozen(id, true)); + assert!(physics.is_frozen(id)); + let mut frozen = Vec::new(); + physics.frozen_positions_into(&mut frozen, 32); + assert_eq!(frozen.len(), 1, "маркер freeze должен найти тело"); + assert!((frozen[0].z - 200.0).abs() < 1.0); + for _ in 0..120 { + physics.step(1.0 / 60.0); + } + let (position, _) = physics.transform(id).expect("трансформ"); + assert!( + (position.z - 200.0).abs() < 1.0, + "замороженный проп не должен падать, z={}", + position.z + ); + + assert!(physics.set_frozen(id, false)); + assert!(!physics.is_frozen(id)); + for _ in 0..300 { + physics.step(1.0 / 60.0); + } + let (position, _) = physics.transform(id).expect("трансформ"); + assert!( + position.z < 60.0, + "размороженный проп должен упасть, z={}", + position.z + ); + } + + /// Масса не теряется при заморозке: физган ограничивает массу и после + /// `freeze`, а Rapier у fixed-тела может вернуть ноль. + #[test] + fn mass_survives_freeze() { + let mut physics = PhysicsWorld::new(); + physics + .add_prop( + EntityIndex::INVALID, + Vec3::new(0.0, 0.0, 100.0), + Vec3::ZERO, + Vec3::new(16.0, 16.0, 16.0), + 42.0, + ) + .expect("тело"); + let id = physics.body_of(EntityIndex::INVALID).expect("нашли тело"); + let mass = physics.mass(id).expect("масса"); + assert!((mass - 42.0).abs() < 0.5, "масса {mass}"); + physics.set_frozen(id, true); + let mass = physics.mass(id).expect("масса после freeze"); + assert!((mass - 42.0).abs() < 0.5, "масса после freeze {mass}"); + } + + /// Скорость, заданная физганом, должна дойти до тела. + #[test] + fn velocity_can_be_driven_by_physgun() { + let mut physics = PhysicsWorld::new(); + physics + .add_prop( + EntityIndex::INVALID, + Vec3::new(0.0, 0.0, 100.0), + Vec3::ZERO, + Vec3::new(16.0, 16.0, 16.0), + 10.0, + ) + .expect("тело"); + let id = physics.body_of(EntityIndex::INVALID).expect("нашли тело"); + physics.set_gravity_scale(id, 0.0); + assert!(physics.set_velocity(id, Vec3::new(100.0, 0.0, 0.0))); + physics.step(1.0 / 60.0); + let velocity = physics.velocity(id).expect("скорость"); + assert!( + (velocity.x - 100.0).abs() < 1.0, + "скорость должна быть 100, получили {velocity:?}" + ); + } + /// Проп, провалившийся за пределы карты, должен попадать в список на возврат. #[test] fn falls_through_world_is_detected() { diff --git a/crates/engine/src/player.rs b/crates/engine/src/player.rs index eee8793..e4264bd 100644 --- a/crates/engine/src/player.rs +++ b/crates/engine/src/player.rs @@ -390,6 +390,10 @@ pub enum DebugLineKind { TerrainTri, /// Ребро тела (игрока, пропа). Body, + /// Луч физгана от «дула» к удерживаемому телу. + Physgun, + /// Замороженный физганом проп (маркер, чтобы freeze был виден). + Frozen, } /// Линия отладочного слоя: две точки и вид. @@ -5296,6 +5300,7 @@ mod tests { first_vertex, vertex_count: vertices.len() as u32, physics_body: Some(body), + removed: false, }); let index = world.props.len() - 1; diff --git a/crates/engine/src/world.rs b/crates/engine/src/world.rs index f04b07f..a45cd7c 100644 --- a/crates/engine/src/world.rs +++ b/crates/engine/src/world.rs @@ -36,6 +36,13 @@ const SURF_WARP: i32 = 0x8; /// Грань не рисуется (кирпичи `clip`, `trigger`, `func_brush`). pub const SURF_NODRAW: i32 = 0x80; +/// Куда уводится геометрия удалённого пропа. +/// +/// Вырезать вершины из общих буферов дорого и ломает индексы батчей, поэтому +/// undo спавна прячет меш под картой, а физику/сущность удаляет. Восстановление +/// возвращает вершины на место без пересборки мира. +const HIDDEN_PROP_Z: f32 = -100_000.0; + /// Принадлежность грани модели карты и смещение для её рисования. /// /// `LUMP_FACES` хранит полигоны всех моделей подряд, а не только статического @@ -1486,6 +1493,7 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result { first_vertex: world.vertices.len() as u32, vertex_count: 0, physics_body: None, + removed: false, }); // Материалы модели добавляем в мир (кэш по имени текстуры). @@ -2733,6 +2741,9 @@ impl World { self.physics.step(dt); let mut moved = 0usize; for index in 0..self.props.len() { + if self.props[index].removed { + continue; + } let Some(body) = self.props[index].physics_body else { continue; }; @@ -3056,11 +3067,132 @@ impl World { first_vertex, vertex_count, physics_body, + removed: false, }); self.update_prop_bounds(); Some(self.props.len() - 1) } + /// Удаляет проп, заспавненный клиентом: тело, сущность и видимость. + /// + /// Геометрия не вырезается из буферов (это сломало бы индексы батчей), а + /// уводится под карту; `restore_prop` вернёт её на место. Возвращает + /// `false`, если индекса нет или проп уже удалён. + pub fn remove_prop(&mut self, index: usize) -> bool { + let Some(prop) = self.props.get(index) else { + return false; + }; + if prop.removed { + return false; + } + let (entity, body, mesh) = (prop.entity, prop.physics_body, prop.mesh); + let (first, count, angles) = (prop.first_vertex, prop.vertex_count, prop.angles); + + self.props[index].removed = true; + self.props[index].physics_body = None; + if let Some(body) = body { + let _ = self.physics.remove(body); + } + let _ = self.entities.remove(entity); + // Скрываем меш под картой: батч остаётся, но рисует пустоту. + self.rewrite_instance( + mesh, + first, + count, + Vec3::new(0.0, 0.0, HIDDEN_PROP_Z), + angles, + 0.0, + ); + self.update_prop_bounds(); + true + } + + /// Возвращает удалённый проп на место: сущность, тело и геометрию. + /// + /// Геометрия уже лежит в общих буферах, поэтому пересоздаём только + /// сущность и тело. `mass` — та же, что при спавне (undo хранит её в + /// действии). + pub fn restore_prop(&mut self, index: usize, mass: f32) -> bool { + let Some(prop) = self.props.get(index) else { + return false; + }; + if !prop.removed { + return false; + } + let (classname, model, origin, angles, mesh) = ( + prop.classname.clone(), + prop.model.clone(), + prop.origin, + prop.angles, + prop.mesh, + ); + + let mut entity = crate::entity::Entity::new(classname); + entity.model = Some(model); + entity.origin = origin; + entity.angles = angles; + let Some(entity_index) = self.entities.spawn(entity) else { + return false; + }; + + let body = self + .prop_meshes + .get(mesh) + .map(|mesh| { + ( + mesh.bounds_half_extents, + mesh.bounds_center, + mesh.positions.clone(), + ) + }) + .and_then(|(half, centre, positions)| { + if half.length_squared() <= 1.0 { + return None; + } + self.physics + .add_prop_hull(entity_index, origin, angles, &positions, mass.max(0.1)) + .or_else(|_| { + self.physics.add_prop_offset( + entity_index, + origin, + angles, + half, + centre, + mass.max(0.1), + ) + }) + .ok() + }); + + self.props[index].entity = entity_index; + self.props[index].physics_body = body; + self.props[index].removed = false; + // Возвращаем вершины из-под карты и синхронизируем сущность. Если + // геометрию вернуть нечем, откатываем сущность/тело: невидимая + // сущность в пуле хуже, чем неудачный redo. + if self.move_prop(index, origin, angles) { + true + } else { + if let Some(body) = body { + let _ = self.physics.remove(body); + } + let _ = self.entities.remove(entity_index); + self.props[index].physics_body = None; + self.props[index].removed = true; + false + } + } + + /// Индекс экземпляра пропа по сущности. + pub fn prop_index_of_entity(&self, entity: crate::entity::EntityIndex) -> Option { + self.props.iter().position(|prop| prop.entity == entity) + } + + /// Сколько пропов реально живо (без удалённых undo). + pub fn alive_prop_count(&self) -> usize { + self.props.iter().filter(|prop| !prop.removed).count() + } + /// Есть ли у экземпляра анимация с таким именем (своя или включённой /// модели). Нужно клиенту, чтобы выбрать существующую секвенцию состояния /// и не сбрасывать модель в позу покоя из-за опечатки в имени. @@ -3271,6 +3403,10 @@ impl World { let Some(instance) = self.props.get(index) else { return false; }; + // Удалённый проп не воскрешаем случайным вызовом из физики/Lua. + if instance.removed { + return false; + } let (mesh_index, first, count) = (instance.mesh, instance.first_vertex, instance.vertex_count); let entity = instance.entity; @@ -3354,6 +3490,9 @@ impl World { pub fn prop_collision_boxes(&self) -> Vec { let mut out = Vec::with_capacity(self.props.len()); for prop in &self.props { + if prop.removed { + continue; + } let Some(mesh) = self.prop_meshes.get(prop.mesh) else { continue; }; @@ -3395,6 +3534,9 @@ impl World { let mut min = Vec3::splat(f32::MAX); let mut max = Vec3::splat(f32::MIN); for instance in &self.props { + if instance.removed { + continue; + } let first = instance.first_vertex as usize; let count = instance.vertex_count as usize; if first + count > self.vertices.len() { @@ -3737,6 +3879,13 @@ pub struct PropInstance { /// У `prop_static` физики в GMod нет — он навечно прибит к карте, — и /// поле остаётся пустым. pub physics_body: Option, + /// Проп удалён (undo спавна) и скрыт под картой. + /// + /// Геометрию из общих буферов не вырезать дёшево, поэтому она уводится + /// далеко вниз и пропускается физикой, коллизией и отрисовкой батча (тот + /// остаётся, но рисует пустоту за пределами карты). `restore_prop` + /// возвращает проп на место без пересборки мира. + pub removed: bool, } /// Источник света из карты (`light_environment`, `light`, `light_spot`). @@ -4097,6 +4246,68 @@ fn map_ambient(entities: &[formats::KvDocument]) -> [f32; 3] { #[cfg(test)] mod tests { use super::*; + + /// Мир с одним кубическим мешом и без пропов: для тестов удаления/undo. + fn box_world() -> World { + let positions = vec![ + [-16.0, -16.0, -16.0], + [16.0, -16.0, -16.0], + [16.0, 16.0, -16.0], + [-16.0, 16.0, -16.0], + [-16.0, -16.0, 16.0], + [16.0, -16.0, 16.0], + [16.0, 16.0, 16.0], + [-16.0, 16.0, 16.0], + ]; + let normals = vec![[0.0, 0.0, 1.0]; positions.len()]; + let vertices = positions + .iter() + .map(|pos| WorldVertex { + pos: *pos, + normal: [0.0, 0.0, 1.0], + uv: [0.0, 0.0], + light: [1.0, 1.0, 1.0], + lightmap_uv: [0.0, 0.0], + }) + .collect(); + World { + vertices, + indices: Vec::new(), + batches: Vec::new(), + materials: Vec::new(), + spawn: Vec3::ZERO, + spawn_yaw: 0.0, + bounds_min: Vec3::splat(-100.0), + bounds_max: Vec3::splat(100.0), + stats: WorldStats::default(), + fog: None, + ambient: [0.62, 0.66, 0.74], + skybox: None, + disp_tris: Vec::new(), + sky_mesh: None, + lights: Vec::new(), + lightmap: None, + entities: crate::entity::EntityWorld::new(), + model_instances: Vec::new(), + mirrors: Vec::new(), + moved_ranges: Vec::new(), + moved_index_ranges: Vec::new(), + props: Vec::new(), + prop_meshes: vec![PropMesh { + bones: Vec::new(), + skins: Vec::new(), + indices: Vec::new(), + anim: Vec::new(), + positions, + normals, + bounds_center: Vec3::ZERO, + bounds_half_extents: Vec3::new(16.0, 16.0, 16.0), + }], + physics: crate::physics::PhysicsWorld::new(), + home_origins: Vec::new(), + light_samples: Vec::new(), + } + } use crate::entity::EntityWorld; /// Небо гасится только за пределами карты. @@ -5235,6 +5446,7 @@ mod tests { first_vertex: 100, vertex_count: 24, physics_body: None, + removed: false, }; // Сущность жива — ссылка разрешается, и класс совпадает. let entity = world.get(instance.entity).expect("сущность жива"); @@ -5292,6 +5504,7 @@ mod tests { first_vertex: 0, vertex_count: 1, physics_body: None, + removed: false, }], indices: Vec::new(), batches: Vec::new(), @@ -5319,6 +5532,80 @@ mod tests { assert_eq!(world.props[0].origin, Vec3::new(100.0, 0.0, 0.0)); } + /// Undo спавна: удаление убирает тело/сущность и прячет меш, восстановление + /// возвращает всё на прежнюю позу без пересборки мира. + #[test] + fn remove_and_restore_spawned_prop() { + let mut world = box_world(); + let origin = Vec3::new(0.0, 0.0, 64.0); + let mut entity = crate::entity::Entity::new("prop_physics".to_string()); + entity.model = Some("models/test/box.mdl".to_string()); + entity.origin = origin; + let entity_index = world.entities.spawn(entity).expect("сущность"); + let positions = world.prop_meshes[0].positions.clone(); + let body = world + .physics + .add_prop_hull(entity_index, origin, Vec3::ZERO, &positions, 10.0) + .expect("тело"); + world.props.push(PropInstance { + entity: entity_index, + classname: "prop_physics".into(), + model: "models/test/box.mdl".into(), + mesh: 0, + origin, + angles: Vec3::ZERO, + first_vertex: 0, + vertex_count: positions.len() as u32, + physics_body: Some(body), + removed: false, + }); + // Геометрия стоит на месте до удаления. + assert!(world.move_prop(0, origin, Vec3::ZERO)); + let original = world.vertices[0].pos; + assert!((original[2] - 48.0).abs() < 0.01, "низ коробки на 48"); + + assert!(world.remove_prop(0), "удаление"); + assert!(world.props[0].removed); + assert!(world.props[0].physics_body.is_none()); + assert!( + world.entities.get(entity_index).is_none(), + "сущность удалена" + ); + assert!( + world.vertices[0].pos[2] < -1000.0, + "меш уведён под карту: {}", + world.vertices[0].pos[2] + ); + assert_eq!(world.alive_prop_count(), 0); + // Повторное удаление — не ошибка и не паника. + assert!(!world.remove_prop(0)); + + assert!(world.restore_prop(0, 10.0), "восстановление"); + assert!(!world.props[0].removed); + assert!(world.props[0].physics_body.is_some(), "тело пересоздано"); + assert!( + world.entities.get(world.props[0].entity).is_some(), + "сущность пересоздана" + ); + assert_eq!(world.alive_prop_count(), 1); + assert!( + (world.vertices[0].pos[2] - original[2]).abs() < 0.01, + "меш вернулся: {} вместо {}", + world.vertices[0].pos[2], + original[2] + ); + // Повторное восстановление живого пропа — отказ. + assert!(!world.restore_prop(0, 10.0)); + + // Битая модель: restore обязан откатить сущность, а не оставить + // невидимый проп в пуле. + assert!(world.remove_prop(0)); + world.props[0].mesh = 99; + assert!(!world.restore_prop(0, 10.0)); + assert!(world.props[0].removed); + assert!(world.props[0].physics_body.is_none()); + } + /// Поворот пропа должен поворачивать и нормали, иначе он будет освещаться /// не с той стороны. #[test] @@ -5359,6 +5646,7 @@ mod tests { first_vertex: 0, vertex_count: 1, physics_body: None, + removed: false, }], indices: Vec::new(), batches: Vec::new(), @@ -5434,6 +5722,7 @@ mod tests { first_vertex: 0, vertex_count: 1, physics_body: None, + removed: false, }); assert!( !world.move_prop(0, Vec3::new(1.0, 1.0, 1.0), Vec3::ZERO), @@ -5490,6 +5779,7 @@ mod tests { first_vertex: 0, vertex_count: 0, physics_body: None, + removed: false, }); let moved = Vec3::new(10.0, 20.0, 8.0); diff --git a/crates/render/src/debug_lines.rs b/crates/render/src/debug_lines.rs index a1e025f..8808ac7 100644 --- a/crates/render/src/debug_lines.rs +++ b/crates/render/src/debug_lines.rs @@ -31,6 +31,10 @@ fn color_of(kind: DebugLineKind) -> [f32; 3] { DebugLineKind::TerrainTri => [0.2, 0.9, 1.0], // Зелёный — тело игрока. DebugLineKind::Body => [0.2, 1.0, 0.3], + // Голубой луч физгана: виден сквозь геометрию, как трассер. + DebugLineKind::Physgun => [0.35, 0.9, 1.0], + // Жёлтый маркер замороженного пропа. + DebugLineKind::Frozen => [1.0, 0.85, 0.15], } } diff --git a/docs/DIARY.md b/docs/DIARY.md index 5712941..0117055 100644 --- a/docs/DIARY.md +++ b/docs/DIARY.md @@ -8,6 +8,54 @@ --- +## 0.11. Sandbox: физган, freeze и undo (2026-10-24, ветка `feat/sandbox`) + +Физган стал отдельным инструментом Sandbox, а не «побочным эффектом ЛКМ». + +- **Выбор:** `F` включает/выключает физган. Если SWEP не выбран, физган + активен сразу (прежнее поведение). Слоты `0-9` по-прежнему выбирают SWEP и + выключают физган. `SOURCE_RS_PHYSGUN=1` включает принудительно и держит + ближайший проп без мыши. +- **ЛКМ:** захват ближайшего динамического пропа по лучу (дальность 250, + допуск 40 по перпендикуляру, масса до 500, игрок и статика не берутся). + Удержание — скоростью: экспоненциальное приближение к точке на луче, + гравитация на время захвата выключена. Это не телепорт: проп сталкивается с + миром и плавно догоняет цель. +- **Колесо:** доворачивает `target_yaw`; тело догоняет угол угловой скоростью + (до 720°/с), а не мгновенной позой. +- **ПКМ:** заморозка. Удерживаемый проп фиксируется и отпускается; свободный + под прицелом переключается `fixed` ↔ `dynamic` (аналог `PhysgunFreeze`). + Замороженные пропы помечены жёлтым крестом, чтобы freeze был виден кадром. +- **Визуал:** пучок из пяти линий от смещённого «дула» (справа-снизу, как + вьюмодель) к пропу + крест в точке захвата. Одиночная линия из центра глаз + при взгляде прямо на проп проецируется в точку и не видна. +- **Undo:** стек на 32 действия, `Z` — откат, `Shift+Z` — повтор. + Откатывает: спавн (удаляет тело/сущность и уводит меш под карту), + перемещение/поворот физганом (возвращает позу), заморозку (возвращает + состояние). Новое действие обнуляет redo. +- **Грабли удаления:** вырезать вершины из общих буферов дорого и ломает + индексы батчей, поэтому `World::remove_prop` прячет геометрию на + `z=-100000`, а `restore_prop` возвращает её и пересоздаёт сущность/тело. + Визуально проп исчезает, но память под его вершины остаётся — осознанный + компромисс до общей системы удаления сущностей. +- **Бинды/HUD:** нижняя подсказка теперь в две строки: + `WASD/Space/Ctrl/V/F/ЛКМ/ПКМ/колесо` и + `G spawn | Q menu | Z undo | Shift+Z redo | 0-9 weapon | \` log`. + В строке режима — `physgun [F] HOLD <дистанция> undo /`. +- **Проверки:** `SOURCE_RS_TEST_PHYSGUN=1` (спавн арбуза, авто-захват, + поворот +25°, freeze на 40-м кадре) и `SOURCE_RS_TEST_UNDO=1` + (`alive 10 → 9`), `SOURCE_RS_TEST_REDO=1` (`9 → 10`). На 35-м кадре виден + пучок луча и крест на арбузе; на 60-м — жёлтый маркер freeze и `speed=0`. + Клавиши проверены `xdotool`: `G` спавнит, `Z` откатывает (`depth=0`), + `Shift+Z` возвращает (`depth=1`), `F` дважды пересоздаёт вьюмодель без + падения. +- **Осталось:** звук и эффекты физгана, физические силы/инерция как в GMod + (сейчас P-регулятор скорости), серверный `PhysgunFreeze`, подсветка цели + силуэтом, `v_physgun.mdl` в ассетах нет — вьюмодель откатывается на + пистолет. + +--- + ## 0.8. Q-меню Sandbox в клиенте (2026-10-24, ветка `feat/ui-render`) Настоящее меню из `feat/derma` подключено к клиенту; самодельный `qmenu.rs` diff --git a/docs/PARITY.md b/docs/PARITY.md index cc1382e..cd9f14b 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -3141,6 +3141,14 @@ Sandbox. вьюмодели, проигрывание `SendViewModelMatchingSequence`, урон/эффекты, `DrawHUD` скриптов. +**Сделано (ветка `feat/sandbox`):** физган как отдельный режим `F` (ЛКМ — +захват и плавное удержание скоростью, колесо — доворот, ПКМ — freeze), +дальность 250 и предел массы 500, луч/крест в отладочном слое, жёлтый маркер +замороженного пропа. Undo-стек на 32 действия (`Z`/`Shift+Z`) откатывает +спавн (тело/сущность удаляются, меш уводится под карту), перемещение и +заморозку. Проверки: `SOURCE_RS_TEST_PHYSGUN`, `SOURCE_RS_TEST_UNDO`, +`SOURCE_RS_TEST_REDO`. + ### A24. Q-меню спавна (spawnmenu) Меню спавна из `sandbox/gamemode/spawnmenu` (Lua + VGUI). У нас VGUI нет (P10), поэтому путь такой: сначала **консольные эквиваленты** (`gm_spawn`, `gm_give`,