Sandbox: физган, freeze и undo
- физган отдельным режимом 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
This commit is contained in:
parent
58daab03be
commit
8c5b852613
9 changed files with 1532 additions and 123 deletions
|
|
@ -10,6 +10,7 @@ mod menu;
|
|||
mod net_cmd;
|
||||
mod pack;
|
||||
mod qmenu;
|
||||
mod sandbox;
|
||||
mod touch;
|
||||
mod ui_overlay;
|
||||
mod weapon;
|
||||
|
|
|
|||
|
|
@ -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<f32> {
|
|||
Some(value.parse::<f32>().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<f32>,
|
||||
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<engine::DebugLine>,
|
||||
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<i32> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Модель для текущего состояния: override отладки важнее SWEP.
|
||||
/// Модель для текущего состояния: override отладки важнее физгана и SWEP.
|
||||
fn desired_view_model(
|
||||
override_path: &Option<Option<String>>,
|
||||
swep: Option<&crate::weapon::SwepState>,
|
||||
physgun_active: bool,
|
||||
) -> Option<String> {
|
||||
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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<sandbox::Grab> = 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<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
|
|||
|
||||
// Анимация модели игрока по состоянию (A26): тип и фаза.
|
||||
let mut player_animation = engine::AnimationState::default();
|
||||
// Переиспользуемые буферы отладочных линий: луч физгана и маркеры freeze
|
||||
// не должны аллоцировать Vec каждый кадр.
|
||||
let mut debug_extra: Vec<engine::DebugLine> = Vec::new();
|
||||
let mut frozen_markers: Vec<math::Vec3> = Vec::new();
|
||||
|
||||
'running: loop {
|
||||
for event in events.poll_iter() {
|
||||
|
|
@ -890,7 +935,11 @@ pub fn map(assets: Option<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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)?;
|
||||
|
|
|
|||
504
crates/client/src/sandbox.rs
Normal file
504
crates/client/src/sandbox.rs
Normal file
|
|
@ -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<SandboxAction>,
|
||||
redo: Vec<SandboxAction>,
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<Grab> {
|
||||
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<FreezeToggle> {
|
||||
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<usize> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -145,6 +145,12 @@ pub struct PhysicsWorld {
|
|||
pub steps: u64,
|
||||
/// Соединения между телами (шарниры, верёвки, сварки).
|
||||
pub(crate) constraints: Vec<ImpulseJointHandle>,
|
||||
/// Массы динамических тел на момент создания.
|
||||
///
|
||||
/// `RigidBody::mass()` у замороженного (`fixed`) тела может обнулиться, а
|
||||
/// физгану нужно ограничение массы и после заморозки. Поэтому храним
|
||||
/// исходную массу отдельно от Rapier.
|
||||
masses: HashMap<RigidBodyHandle, f32>,
|
||||
/// Сколько статических коллайдеров построено (рельеф, пол).
|
||||
///
|
||||
/// Отдельно от `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<f32> {
|
||||
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<Vec3>, 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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<World, WorldError> {
|
|||
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<usize> {
|
||||
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<math::Aabb> {
|
||||
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<crate::physics::BodyId>,
|
||||
/// Проп удалён (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);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <n>/<m>`.
|
||||
- **Проверки:** `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`
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue