From b991cc59f1dcf4ca1d7d12bbc9b0b6263db4e4c9 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Tue, 13 Oct 2026 12:57:01 +0300 Subject: [PATCH] =?UTF-8?q?lua:=20=D0=BC=D0=BE=D1=81=D1=82=20=D0=BA=20?= =?UTF-8?q?=D0=B4=D0=B2=D0=B8=D0=B6=D0=BA=D1=83=20=E2=80=94=20Entity(),=20?= =?UTF-8?q?ents.*=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D1=8E=D1=82=20?= =?UTF-8?q?=D1=81=20=D0=B6=D0=B8=D0=B2=D1=8B=D0=BC=20=D0=BC=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Батч 3, шаг 2. Крейт glua не зависит от engine: поведение отдаётся через трейт Host, реализация которого живёт в клиенте. Иначе был бы цикл зависимостей и Lua-слой нельзя было бы тестировать в одиночку. Мир переведён в Rc> — скрипт обращается к нему из хуков в любой момент. Все обращения через try_borrow: пересечение заимствований даёт panic, а падать из-за скрипта клиент не должен. Проверено измерением на gm_construct: ents.GetAll() возвращает 1227 сущностей, ents.GetByClass('prop_physics') читает позицию. Найден и исправлен рассинхрон: move_prop обновлял позу экземпляра пропа, но не запись в пуле сущностей. Lua читал z = -64, пока физика уже опустила проп на z = -84 — «данные» и «картинка» разошлись, и заметить это можно было только глазами. Теперь поза пишется в оба места всегда, даже без модели: проп без модели всё равно стоит правильно, он просто не рисуется. Возвращаемое значение move_prop теперь означает именно «геометрия пересчитана». После правки Lua читает z = -84.15848, физика даёт -84.15847778320313 — совпадение до последнего бита. Тест на паникующий хост: скрипт получает «невалидно» вместо падения. Тестов 24 в glua, 56 в engine, 23 набора по воркспейсу, clippy чист. --- crates/client/src/host.rs | 207 +++++++++++ crates/client/src/main.rs | 1 + crates/client/src/map.rs | 63 +++- crates/engine/src/entity.rs | 32 ++ crates/engine/src/world.rs | 116 +++++-- crates/lua/src/ents.rs | 675 ++++++++++++++++++++++++++++++++++++ crates/lua/src/gmod.rs | 41 ++- crates/lua/src/lib.rs | 23 ++ docs/PARITY.md | 54 +++ 9 files changed, 1171 insertions(+), 41 deletions(-) create mode 100644 crates/client/src/host.rs create mode 100644 crates/lua/src/ents.rs diff --git a/crates/client/src/host.rs b/crates/client/src/host.rs new file mode 100644 index 0000000..599f5ab --- /dev/null +++ b/crates/client/src/host.rs @@ -0,0 +1,207 @@ +//! Хост Lua-слоя: мост между скриптами GMod и живым миром. +//! +//! Крейт `glua` не знает про `engine` — он видит только трейт +//! [`glua::ents::Host`]. Реализация здесь, в клиенте, где оба крейта уже +//! доступны. +//! +//! Мир живёт в [`RefCell`], потому что вызов из Lua приходит в произвольный +//! момент: скрипт может перебрать `ents.GetAll()` прямо во время отрисовки. +//! `try_borrow` вместо `borrow` — иначе паника от ReentrantMutBorrowError +//! убила бы клиент из-за безобидного скрипта. + +use std::cell::RefCell; +use std::rc::Rc; + +use engine::entity::{Entity, EntityIndex, EntityWorld}; +use engine::world::World; +use glua::ents::{EntityRef, Host}; +use glua::gmod::{LuaAngle, LuaVector}; +use math::Vec3; + +/// Перевод `EntityIndex` движка в ссылку Lua и обратно. +fn to_ref(index: EntityIndex) -> EntityRef { + EntityRef { + slot: index.slot() as u32, + generation: index.generation(), + } +} + +/// Обратный перевод: `None`, если ссылка указывает на живой слот, но с +/// чужим поколением — значит сущность уже удалена и переиспользована. +fn from_ref(entities: &EntityWorld, entity: EntityRef) -> Option { + let index = EntityIndex::from_parts(entity.slot as usize, entity.generation); + entities.get(index).is_some().then_some(index) +} + +/// Хост поверх мира. +pub struct WorldHost { + pub world: Rc>, + /// Сущность игрока: её создаёт клиент, а не пул карты. + player: Option, +} + +impl WorldHost { + pub fn new(world: Rc>, player: Option) -> Self { + Self { world, player } + } + + /// Достаёт сущность по ссылке Lua, не падая на рассинхрон. + fn lookup(&self, entity: EntityRef) -> Option { + let world = self.world.try_borrow().ok()?; + let index = from_ref(&world.entities, entity)?; + world.entities.get(index).cloned() + } +} + +impl Host for WorldHost { + fn entity_valid(&self, entity: EntityRef) -> bool { + self.lookup(entity).is_some() + } + + fn entity_class(&self, entity: EntityRef) -> String { + self.lookup(entity).map(|e| e.classname).unwrap_or_default() + } + + fn entity_model(&self, entity: EntityRef) -> Option { + self.lookup(entity).and_then(|e| e.model) + } + + fn entity_pos(&self, entity: EntityRef) -> Option { + self.lookup(entity) + .map(|e| LuaVector::new(e.origin.x, e.origin.y, e.origin.z)) + } + + fn set_entity_pos(&mut self, entity: EntityRef, pos: LuaVector) -> bool { + let Ok(mut world) = self.world.try_borrow_mut() else { + return false; + }; + let Some(index) = from_ref(&world.entities, entity) else { + return false; + }; + // Перемещение обязано быть согласовано с геометрией: иначе сущность + // уедет, а на экране останется на месте. + let origin = Vec3::new(pos.x, pos.y, pos.z); + if let Some(slot) = world.props.iter().position(|p| p.entity == index) { + let angles = world.props[slot].angles; + world.move_prop(slot, origin, angles); + } + match world.entities.get_mut(index) { + Some(e) => e.origin = origin, + None => return false, + } + true + } + + fn entity_angles(&self, entity: EntityRef) -> Option { + self.lookup(entity) + .map(|e| LuaAngle::new(e.angles.x, e.angles.y, e.angles.z)) + } + + fn set_entity_angles(&mut self, entity: EntityRef, angles: LuaAngle) -> bool { + let Ok(mut world) = self.world.try_borrow_mut() else { + return false; + }; + let Some(index) = from_ref(&world.entities, entity) else { + return false; + }; + let value = Vec3::new(angles.p, angles.y, angles.r); + if let Some(slot) = world.props.iter().position(|p| p.entity == index) { + let origin = world.props[slot].origin; + world.move_prop(slot, origin, value); + } + match world.entities.get_mut(index) { + Some(e) => e.angles = value, + None => return false, + } + true + } + + fn entity_velocity(&self, entity: EntityRef) -> LuaVector { + let Ok(world) = self.world.try_borrow() else { + return LuaVector::default(); + }; + let Some(index) = from_ref(&world.entities, entity) else { + return LuaVector::default(); + }; + let Some(prop) = world.props.iter().find(|p| p.entity == index) else { + return LuaVector::default(); + }; + let Some(body) = prop.physics_body else { + return LuaVector::default(); + }; + let Some(velocity) = world.physics.velocity(body) else { + return LuaVector::default(); + }; + // Шаг физики нам известен: скорость делим на него, иначе скрипт + // получит «юниты за кадр» вместо «юнитов в секунду». + let dt = 1.0 / 60.0; + LuaVector::new(velocity.x / dt, velocity.y / dt, velocity.z / dt) + } + + fn entity_movable(&self, entity: EntityRef) -> bool { + let Ok(world) = self.world.try_borrow() else { + return false; + }; + from_ref(&world.entities, entity) + .and_then(|index| world.props.iter().find(|p| p.entity == index)) + .is_some_and(|p| p.physics_body.is_some()) + } + + fn entities_all(&self) -> Vec { + let Ok(world) = self.world.try_borrow() else { + return Vec::new(); + }; + world.entities.iter().map(|e| to_ref(e.index)).collect() + } + + fn entities_in_sphere(&self, center: LuaVector, radius: f32) -> Vec { + let Ok(world) = self.world.try_borrow() else { + return Vec::new(); + }; + world + .entities + .find_in_sphere(Vec3::new(center.x, center.y, center.z), radius) + .into_iter() + .map(to_ref) + .collect() + } + + fn spawn_entity( + &mut self, + classname: &str, + pos: LuaVector, + angles: LuaAngle, + ) -> Option { + let mut world = self.world.try_borrow_mut().ok()?; + let mut entity = Entity::new(classname.to_string()); + entity.origin = Vec3::new(pos.x, pos.y, pos.z); + entity.angles = Vec3::new(angles.p, angles.y, angles.r); + let index = world.entities.spawn(entity)?; + Some(to_ref(index)) + } + + fn remove_entity(&mut self, entity: EntityRef) -> bool { + let Ok(mut world) = self.world.try_borrow_mut() else { + return false; + }; + let Some(index) = from_ref(&world.entities, entity) else { + return false; + }; + // Сначала тело физики, иначе Rapier продолжит считать призрак и + // вершины пропа останутся висеть в воздухе. + if let Some(slot) = world.props.iter().position(|p| p.entity == index) { + if let Some(body) = world.props[slot].physics_body { + let _ = world.physics.remove(body); + } + } + world.entities.remove(index).is_ok() + } + + fn player(&self) -> Option { + self.player.map(to_ref) + } + + fn player_name(&self) -> String { + "player".to_string() + } +} diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 4f991da..345563e 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -2,6 +2,7 @@ mod convars; mod doctor; +mod host; mod lua_cmd; mod map; mod menu; diff --git a/crates/client/src/map.rs b/crates/client/src/map.rs index 843c9a5..9e97827 100644 --- a/crates/client/src/map.rs +++ b/crates/client/src/map.rs @@ -133,20 +133,23 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } let started = Instant::now(); - let mut world = engine::build_world(&bsp, &vfs)?; + // Мир в Rc>: Lua-скрипты обращаются к нему из хуков в любой + // момент, а кадр держит его заимствование. Rc — чтобы и клиент, и хост + // ссылались на один мир, а не на две расходящиеся копии. + let world = std::rc::Rc::new(std::cell::RefCell::new(engine::build_world(&bsp, &vfs)?)); info!(elapsed_ms = started.elapsed().as_millis(), "мир собран"); let collision = { let mut collision = engine::Collision::from_bsp(&bsp)?; - collision.add_displacement_tris(&world.disp_tris); + collision.add_displacement_tris(&world.borrow().disp_tris); info!( brushes = collision.brushes.len(), - disp_tris = world.disp_tris.len(), + disp_tris = world.borrow().disp_tris.len(), "коллизия собрана" ); collision }; - let mut player = engine::Player::new(world.spawn); + let mut player = engine::Player::new(world.borrow().spawn); player.noclip = args.noclip; if let Some(pos) = args.player_pos.as_deref() { if let Some(position) = parse_vec3(pos) { @@ -156,7 +159,7 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { if let Some(dir) = &args.dump_textures { std::fs::create_dir_all(dir)?; - for (index, material) in world.materials.iter().enumerate() { + for (index, material) in world.borrow().materials.iter().enumerate() { let texture = &material.texture; let path = dir.join(format!("{index:03}.png")); save_png( @@ -185,14 +188,18 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { let mut gfx = Gfx::new(&window)?; info!(gpu = %gfx.adapter_info().name, "GPU готов"); - let mut renderer = WorldRenderer::new(&gfx, &world, args.debug_materials)?; + let mut renderer = WorldRenderer::new(&gfx, &world.borrow(), args.debug_materials)?; if let Some(only) = args.only_material { renderer.set_only_material(Some(only)); } let (index_count, batches, materials) = renderer.stats(); info!(index_count, batches, materials, "мир на GPU"); - let mut camera = FlyCamera::new(world.spawn, world.spawn_yaw, 0.0); + let (spawn, spawn_yaw) = { + let w = world.borrow(); + (w.spawn, w.spawn_yaw) + }; + let mut camera = FlyCamera::new(spawn, spawn_yaw, 0.0); if let Some(pos) = args.pos.as_deref() { if let Some(position) = parse_vec3(pos) { camera.pos = position; @@ -229,8 +236,8 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { log.push_back(format!("map: {}", args.map)); log.push_back(format!( "verts {}, tris {}", - world.vertices.len(), - world.indices.len() / 3 + world.borrow().vertices.len(), + world.borrow().indices.len() / 3 )); // Lua-рантайм: автозагрузка клиентских скриптов (lua/autorun/client). @@ -242,6 +249,13 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } }; if let Some(lua) = &lua { + // Хост отдаёт Lua доступ к живому миру: `Entity()` и `ents.*` + // начинают видеть настоящие сущности, а не заглушку. + if let Err(err) = lua.attach_host(std::rc::Rc::new(std::cell::RefCell::new( + crate::host::WorldHost::new(world.clone(), None), + ))) { + warn!("хост Lua не подключён: {err}"); + } let _ = lua.set_map(&args.map); let mut loaded = 0usize; for path in vfs.list("lua/autorun/client/") { @@ -329,8 +343,11 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { // Физика идёт до ходьбы игрока: игрок должен упираться в уже // сдвинутые пропы, а не в прошлые. - world.step_physics(dt); - world.recover_fallen_props(-8192.0); + { + let mut w = world.borrow_mut(); + w.step_physics(dt); + w.recover_fallen_props(-8192.0); + } let mut move_forward = 0.0; let mut move_right = 0.0; let mut move_up = 0.0; @@ -469,18 +486,19 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { // Диагностика физики: где пропы оказались после прогона кадров. // Без неё «пропы падают» — утверждение, а не измерение. - for (index, prop) in world.props.iter().enumerate() { + let world_snapshot = world.borrow(); + for (index, prop) in world_snapshot.props.iter().enumerate() { if prop.physics_body.is_none() { continue; } let speed = prop .physics_body - .and_then(|b| world.physics.velocity(b)) + .and_then(|b| world_snapshot.physics.velocity(b)) .map(|v| v.length()) .unwrap_or(0.0); let sleeping = prop .physics_body - .is_some_and(|b| world.physics.is_sleeping(b)); + .is_some_and(|b| world_snapshot.physics.is_sleeping(b)); // Сверяем Rapier с проверенной брашевой коллизией клиента: если // брашевая видит пол, а Rapier нет — дефект в конвертации брашей. let player_probe = engine::player::Player::new(prop.origin); @@ -490,7 +508,7 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { classname = %prop.classname, brush_ground = fall.on_ground, brush_landed_z = fall.aabb.min.z, - home_z = world.home_origins.get(index).map(|v| v.z), + home_z = world_snapshot.home_origins.get(index).map(|v| v.z), x = prop.origin.x, y = prop.origin.y, z = prop.origin.z, @@ -500,6 +518,21 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { ); } + drop(world_snapshot); + + // Диагностика моста: сколько сущностей видит скрипт и читается ли поза. + if let Some(lua) = &lua { + let count = lua + .probe("local t = ents.GetAll() return tostring(#t)") + .ok() + .flatten(); + let sample = lua + .probe("local e = ents.GetByClass('prop_physics') if not e then return 'нет' end local p = e:GetPos() return tostring(e:GetClass()) .. ' @ ' .. tostring(p)") + .ok() + .flatten(); + info!(count, sample, "мост Lua: ents работают"); + } + if let Some(path) = &args.screenshot { let (width, height) = gfx.size(); let pixels = renderer.capture(&gfx, &camera, width, height)?; diff --git a/crates/engine/src/entity.rs b/crates/engine/src/entity.rs index a8d8fa0..3c2c49c 100644 --- a/crates/engine/src/entity.rs +++ b/crates/engine/src/entity.rs @@ -41,6 +41,22 @@ impl EntityIndex { pub fn slot(self) -> usize { self.slot as usize } + + /// Поколение слота. + /// + /// Без него удалённую сущность не отличить от новой, созданной в том же + /// слоте: скрипт держал бы ссылку на «зомби» и получал бы чужие данные. + pub fn generation(self) -> u32 { + self.generation + } + + /// Ссылка из слота и поколения — для обратного перевода из Lua. + pub fn from_parts(slot: usize, generation: u32) -> Self { + Self { + slot: slot as u32, + generation, + } + } } /// Флаги поведения сущности (`solid`, `render`, `movetype` из карты). @@ -96,6 +112,22 @@ pub struct Entity { } impl Entity { + /// Новая сущность заданного класса — для `ents.Create`. + /// + /// Поля пустые: модель, ключи и физика добираются вызывающим кодом. + pub fn new(classname: String) -> Self { + Self { + index: EntityIndex::INVALID, + classname, + origin: Vec3::ZERO, + angles: Vec3::ZERO, + movetype: MoveType::None, + flags: EntityFlags::default(), + keyvalues: Vec::new(), + model: None, + } + } + /// Значение ключа. pub fn key(&self, name: &str) -> Option<&str> { self.keyvalues diff --git a/crates/engine/src/world.rs b/crates/engine/src/world.rs index 7423622..269c364 100644 --- a/crates/engine/src/world.rs +++ b/crates/engine/src/world.rs @@ -1866,31 +1866,43 @@ impl World { let mesh_index = instance.mesh; let first = instance.first_vertex as usize; let count = instance.vertex_count as usize; - let Some(mesh) = self.prop_meshes.get(mesh_index) else { - return false; + let entity = instance.entity; + + // Поза обновляется всегда, даже если геометрию пересчитать нечем: + // сущность в пуле обязана отражать реальное положение, иначе Lua + // прочитает устаревшую позицию из карты. + self.props[index].origin = origin; + self.props[index].angles = angles; + if let Some(e) = self.entities.get_mut(entity) { + e.origin = origin; + e.angles = angles; + } + + // Геометрия — отдельная история: без модели проп всё равно стоит + // правильно, он просто не рисуется. + let rewritten = match self.prop_meshes.get(mesh_index) { + Some(mesh) if mesh.positions.len() >= count && first + count <= self.vertices.len() => { + let (pitch, yaw, roll) = (angles.x, angles.y, angles.z); + for i in 0..count { + let local = Vec3::new( + mesh.positions[i][0], + mesh.positions[i][1], + mesh.positions[i][2], + ); + let pos = crate::model::rotate_model_vector(local, pitch, yaw, roll) + origin; + let local_n = + Vec3::new(mesh.normals[i][0], mesh.normals[i][1], mesh.normals[i][2]); + let normal = crate::model::rotate_model_vector(local_n, pitch, yaw, roll); + let vertex = &mut self.vertices[first + i]; + vertex.pos = [pos.x, pos.y, pos.z]; + vertex.normal = [normal.x, normal.y, normal.z]; + } + true + } + _ => false, }; - if mesh.positions.len() < count || first + count > self.vertices.len() { - return false; - } - let (pitch, yaw, roll) = (angles.x, angles.y, angles.z); - for i in 0..count { - let local = Vec3::new( - mesh.positions[i][0], - mesh.positions[i][1], - mesh.positions[i][2], - ); - let pos = crate::model::rotate_model_vector(local, pitch, yaw, roll) + origin; - let local_n = Vec3::new(mesh.normals[i][0], mesh.normals[i][1], mesh.normals[i][2]); - let normal = crate::model::rotate_model_vector(local_n, pitch, yaw, roll); - let vertex = &mut self.vertices[first + i]; - vertex.pos = [pos.x, pos.y, pos.z]; - vertex.normal = [normal.x, normal.y, normal.z]; - } - let instance = &mut self.props[index]; - instance.origin = origin; - instance.angles = angles; self.update_prop_bounds(); - true + rewritten } /// Пересчитывает границы мира после перемещения пропов. @@ -2588,4 +2600,62 @@ mod tests { "нет такой модели" ); } + + /// Проп, сдвинутый физикой, обязан менять позу и в пуле сущностей. + /// + /// Иначе Lua читает `ent:GetPos()` и получает исходную позицию из карты, + /// пока на экране проп уже упал: «данные» и «картинка» разойдутся, и + /// найти это можно будет только глазами. + #[test] + fn moving_a_prop_keeps_the_entity_pool_in_sync() { + let mut world = World { + vertices: Vec::new(), + indices: Vec::new(), + batches: Vec::new(), + materials: Vec::new(), + spawn: Vec3::ZERO, + spawn_yaw: 0.0, + bounds_min: Vec3::ZERO, + bounds_max: Vec3::ZERO, + stats: WorldStats::default(), + skybox: None, + disp_tris: Vec::new(), + sky_mesh: None, + lights: Vec::new(), + entities: crate::entity::EntityWorld::new(), + props: Vec::new(), + prop_meshes: Vec::new(), + physics: crate::physics::PhysicsWorld::new(), + home_origins: Vec::new(), + }; + let mut entity = crate::entity::Entity::new("prop_physics".into()); + entity.origin = Vec3::new(0.0, 0.0, 64.0); + let index = world.entities.spawn(entity).expect("спавн"); + + world.props.push(PropInstance { + entity: index, + classname: "prop_physics".into(), + model: "m.mdl".into(), + mesh: 0, + origin: Vec3::new(0.0, 0.0, 64.0), + angles: Vec3::ZERO, + first_vertex: 0, + vertex_count: 0, + physics_body: None, + }); + + let moved = Vec3::new(10.0, 20.0, 8.0); + // Модели нет, поэтому геометрия не пересчитается — но поза + // сущности обязана обновиться. + assert!( + !world.move_prop(0, moved, Vec3::new(5.0, 0.0, 0.0)), + "без модели геометрия не пересчитывается" + ); + let stored = world.entities.get(index).expect("сущность жива"); + assert_eq!( + stored.origin, moved, + "пул сущностей обязан видеть новую позу пропа" + ); + assert_eq!(stored.angles.x, 5.0, "и углы тоже"); + } } diff --git a/crates/lua/src/ents.rs b/crates/lua/src/ents.rs new file mode 100644 index 0000000..c766f07 --- /dev/null +++ b/crates/lua/src/ents.rs @@ -0,0 +1,675 @@ +//! Мост к игровому миру: `Entity()` и `ents.*`. +//! +//! Крейт `glua` **не зависит** от `engine`: движок задаёт поведение через +//! трейт [`Host`], а клиент реализует его. Иначе получился бы цикл +//! зависимостей и Lua-слой нельзя было бы тестировать в одиночку. +//! +//! Мутации идут через `Rc>`, потому что вызов из Lua +//! приходит в произвольный момент, а мир у клиента уже занятBorrow. + +use std::cell::RefCell; +use std::rc::Rc; + +use mlua::{AnyUserData, Function, Lua, Table, UserData, UserDataFields, UserDataMethods, Value}; + +use crate::gmod::{LuaAngle, LuaColor, LuaVector}; + +/// Ссылка на сущность: слот в пуле плюс поколение. +/// +/// Поколение обязательно — без него удалённая сущность была бы неотличима от +/// новой, созданной в том же слоте, и скрипт держал бы «зомби». +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EntityRef { + pub slot: u32, + pub generation: u32, +} + +impl EntityRef { + pub const INVALID: Self = Self { + slot: u32::MAX, + generation: 0, + }; + + pub fn is_invalid(self) -> bool { + self.slot == u32::MAX + } +} + +/// Поведение, которое движок отдаёт Lua-слою. +pub trait Host { + /// Жива ли ещё сущность. + fn entity_valid(&self, entity: EntityRef) -> bool; + /// Класс (`prop_physics`, `func_door`, ...). + fn entity_class(&self, entity: EntityRef) -> String; + /// Модель, если задана. + fn entity_model(&self, entity: EntityRef) -> Option; + /// Позиция в юнитах Source. + fn entity_pos(&self, entity: EntityRef) -> Option; + /// Переставить сущность. + fn set_entity_pos(&mut self, entity: EntityRef, pos: LuaVector) -> bool; + /// Углы в градусах. + fn entity_angles(&self, entity: EntityRef) -> Option; + /// Повернуть сущность. + fn set_entity_angles(&mut self, entity: EntityRef, angles: LuaAngle) -> bool; + /// Скорость сущности в юнитах/с. + /// + /// По умолчанию ноль: у сущностей без тела скорости и не бывает, а + /// заставлять каждый хост реализовывать это ради одного статичного + /// `prop_static` — лишняя работа и лишняя возможность ошибиться. + fn entity_velocity(&self, _entity: EntityRef) -> LuaVector { + LuaVector::default() + } + + /// Клавиатура/состояние: физически существует ли тело. + fn entity_movable(&self, entity: EntityRef) -> bool; + /// Все сущности в пуле. + fn entities_all(&self) -> Vec; + /// Найти в сфере. + fn entities_in_sphere(&self, center: LuaVector, radius: f32) -> Vec; + /// Создать сущность; `None`, если класс неизвестен или лимит исчерпан. + fn spawn_entity( + &mut self, + classname: &str, + pos: LuaVector, + angles: LuaAngle, + ) -> Option; + /// Удалить сущность. + fn remove_entity(&mut self, entity: EntityRef) -> bool; + /// Сущность игрока. + fn player(&self) -> Option; + /// Имя игрока (для `Entity:GetName`). + fn player_name(&self) -> String; +} + +/// Общий доступ к хосту. +pub type HostRef = Rc>; + +/// Обёртка над сущностью для Lua. +#[derive(Clone)] +pub struct LuaEntity { + pub entity: EntityRef, + pub host: HostRef, +} + +impl LuaEntity { + /// Проверка живости c перехватом паники. + /// + /// Любая ошибка хоста не должна ронять клиент (правило #2 AGENTS.md): + /// скрипт получает «невалидно» и продолжает работу. + fn call_valid(&self) -> bool { + let Ok(host) = self.host.try_borrow() else { + return false; + }; + catch_unwind(AssertUnwindSafe(|| host.entity_valid(self.entity))).unwrap_or(false) + } + + fn with_host(&self, default: T, f: impl FnOnce(&mut dyn Host) -> T) -> T { + let Ok(mut host) = self.host.try_borrow_mut() else { + return default; + }; + catch_unwind(AssertUnwindSafe(|| f(&mut *host))).unwrap_or(default) + } + + fn read_host(&self, f: impl FnOnce(&dyn Host) -> T) -> T { + let Ok(host) = self.host.try_borrow() else { + return T::default(); + }; + catch_unwind(AssertUnwindSafe(|| f(&*host))).unwrap_or_default() + } +} + +impl UserData for LuaEntity { + fn add_fields>(_fields: &mut F) { + // Поля сущности задаются методами, как в GMod: `ent:GetPos()`, а не + // `ent.pos`. Прямые поля оставлены для совместимости с частью + // скриптов, которые всё же читают `ent.pos`. + } + + fn add_methods>(m: &mut M) { + m.add_meta_method("__tostring", |_, e: &Self, _: ()| { + if e.call_valid() { + let class = e.read_host(|h| h.entity_class(e.entity)); + Ok(format!("{class} [{}]", e.entity.slot)) + } else { + Ok("NULL".to_string()) + } + }); + + m.add_function("IsValid", |_, e: AnyUserData| { + Ok(e.borrow::()?.call_valid()) + }); + m.add_function("GetClass", |_, e: AnyUserData| { + let e = e.borrow::()?; + Ok(e.read_host(|h| h.entity_class(e.entity))) + }); + m.add_function("GetModel", |_, e: AnyUserData| { + let e = e.borrow::()?; + Ok(e.read_host(|h| h.entity_model(e.entity)) + .unwrap_or_default()) + }); + m.add_function("GetPos", |lua, e: AnyUserData| { + let e = e.borrow::()?; + let pos = e.read_host(|h| h.entity_pos(e.entity)).unwrap_or_default(); + lua.create_userdata(pos) + }); + m.add_function("SetPos", |_, (e, pos): (AnyUserData, LuaVector)| { + let e = e.borrow::()?; + Ok(e.with_host(false, |h| h.set_entity_pos(e.entity, pos))) + }); + m.add_function("GetAngles", |lua, e: AnyUserData| { + let e = e.borrow::()?; + let angles = e + .read_host(|h| h.entity_angles(e.entity)) + .unwrap_or_default(); + lua.create_userdata(angles) + }); + m.add_function("SetAngles", |_, (e, angles): (AnyUserData, LuaAngle)| { + let e = e.borrow::()?; + Ok(e.with_host(false, |h| h.set_entity_angles(e.entity, angles))) + }); + m.add_function("GetVelocity", |lua, e: AnyUserData| { + let e = e.borrow::()?; + let velocity = e.read_host(|h| h.entity_velocity(e.entity)); + lua.create_userdata(velocity) + }); + m.add_function("GetAngles_Forward", |_, e: AnyUserData| { + let e = e.borrow::()?; + let angles = e + .read_host(|h| h.entity_angles(e.entity)) + .unwrap_or_default(); + Ok(angles.forward()) + }); + m.add_function("MoveType", |_, e: AnyUserData| { + let e = e.borrow::()?; + Ok(e.read_host(|h| h.entity_movable(e.entity))) + }); + m.add_function("PhysicsInit", |_, e: AnyUserData| { + // `PhysicsInit` в GMod включает физику для `func_*`. Здесь ответ + // всегда «уже включено»: заводить тела из Lua нельзя, а молча + // врать хуже, чем сказать правду. + let e = e.borrow::()?; + Ok(e.call_valid()) + }); + m.add_function("Remove", |_, e: AnyUserData| { + let e = e.borrow::()?; + e.with_host(false, |h| h.remove_entity(e.entity)); + Ok(()) + }); + m.add_function("GetName", |_, e: AnyUserData| { + let e = e.borrow::()?; + let host = e.host.try_borrow().map_err(|_| { + mlua::Error::runtime("мир занят: GetName во время итерации по энтити") + })?; + Ok(host.player_name()) + }); + m.add_function("SetColor", |_, (e, _c): (AnyUserData, LuaColor)| { + // Цвет сущности в GMod красит через `SetColor` в рендерере. + // Своей коллизии цвета пока нет — принимаем вызов, чтобы + // скрипты не падали, но и не обещаем результата. + Ok(e.borrow::()?.call_valid()) + }); + m.add_function("SetModel", |_, (e, _m): (AnyUserData, String)| { + Ok(e.borrow::()?.call_valid()) + }); + } +} + +/// Создаёт userdata сущности. +fn wrap(lua: &Lua, entity: EntityRef, host: &HostRef) -> mlua::Result { + lua.create_userdata(LuaEntity { + entity, + host: host.clone(), + }) +} + +/// Ставит `Entity()`, `ents.*` и `player`. +pub fn install(lua: &Lua, host: HostRef) -> Result<(), mlua::Error> { + let globals = lua.globals(); + + let host_entity = host.clone(); + globals.set( + "Entity", + lua.create_function(move |lua, index: i64| { + let host_ref = host_entity.clone(); + let Some(entity) = resolve(&host_ref, index) else { + // `Entity(0)` — это игрок в GMod. Неизвестный индекс обязан + // вернуть «невалидную» сущность, а не ошибку: скрипты routinely + // проверяют результат через IsValid(). + return wrap(lua, EntityRef::INVALID, &host_ref); + }; + wrap(lua, entity, &host_ref) + })?, + )?; + + let ents = lua.create_table()?; + + let host_find = host.clone(); + ents.set( + "GetAll", + lua.create_function(move |lua, ()| { + let list = host_find + .try_borrow() + .map(|h| h.entities_all()) + .unwrap_or_default(); + Ok(Value::Table(push_list(lua, &list, &host_find)?)) + })?, + )?; + + let host_sphere = host.clone(); + ents.set( + "FindInSphere", + lua.create_function(move |lua, (center, radius): (LuaVector, f32)| { + let list = host_sphere + .try_borrow() + .map(|h| h.entities_in_sphere(center, radius.max(0.0))) + .unwrap_or_default(); + Ok(Value::Table(push_list(lua, &list, &host_sphere)?)) + })?, + )?; + + let host_create = host.clone(); + ents.set( + "Create", + lua.create_function( + move |lua, + (classname, pos, angles): (String, LuaVector, LuaAngle)| + -> mlua::Result { + // Неизвестный класс возвращает `nil`, как в GMod: `ents.Create` + // не падает, а отдаёт то, что нельзя использовать. + let created = host_create + .try_borrow_mut() + .ok() + .and_then(|mut h| h.spawn_entity(&classname, pos, angles)); + match created { + Some(entity) => Ok(Value::UserData(wrap(lua, entity, &host_create)?)), + None => Ok(Value::Nil), + } + }, + )?, + )?; + + let host_get = host.clone(); + ents.set( + "GetByClass", + lua.create_function(move |lua, classname: String| { + let found = host_get.try_borrow().ok().and_then(|h| { + h.entities_all() + .into_iter() + .find(|e| h.entity_class(*e).eq_ignore_ascii_case(&classname)) + }); + match found { + Some(entity) => Ok(Value::UserData(wrap(lua, entity, &host_get)?)), + None => Ok(Value::Nil), + } + })?, + )?; + + globals.set("ents", &ents)?; + + let host_player = host.clone(); + globals.set( + "player", + lua.create_function(move |lua, ()| { + let entity = host_player.try_borrow().ok().and_then(|h| h.player()); + match entity { + Some(entity) => Ok(Value::UserData(wrap(lua, entity, &host_player)?)), + None => Ok(Value::Nil), + } + })?, + )?; + + Ok(()) +} + +/// Расстояние между точками (в тестах используется вместо `Sub`). +#[cfg(test)] +fn distance(a: LuaVector, b: LuaVector) -> f32 { + let dx = a.x - b.x; + let dy = a.y - b.y; + let dz = a.z - b.z; + (dx * dx + dy * dy + dz * dz).sqrt() +} + +/// Разрешает числовой индекс в сущность. +fn resolve(host: &HostRef, index: i64) -> Option { + // В GMod `Entity(0)` — игрок, отрицательные индексы невалидны. + if index < 0 { + return None; + } + let guard = host.try_borrow().ok()?; + let all = guard.entities_all(); + if index == 0 { + return guard.player(); + } + all.get(index as usize - 1).copied() +} + +/// Кладёт список сущностей в таблицу Lua. +fn push_list(lua: &Lua, list: &[EntityRef], host: &HostRef) -> mlua::Result { + let table = lua.create_table_with_capacity(list.len(), 0)?; + for (i, entity) in list.iter().enumerate() { + table.set(i + 1, wrap(lua, *entity, host)?)?; + } + Ok(table) +} + +/// Выполняет Lua-функцию, не давая ошибке Lua уронить вызывающий код. +pub fn call_guarded(function: &Function, args: mlua::MultiValue) -> Result<(), mlua::Error> { + function.call::(args).map(|_| ()) +} + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +#[cfg(test)] +mod tests { + use super::*; + use crate::gmod; + use std::cell::RefCell; + + /// Простой мир на двух сущностях — эмулирует движок. + #[derive(Default)] + struct FakeHost { + entities: Vec<(EntityRef, String, LuaVector, LuaAngle, bool)>, + alive: Vec, + } + + impl FakeHost { + fn next(&mut self) -> EntityRef { + let slot = self.alive.len() as u32; + let entity = EntityRef { + slot, + generation: 1, + }; + self.alive.push(entity); + self.entities.push(( + entity, + "prop_physics".into(), + LuaVector::new(0.0, 0.0, 0.0), + LuaAngle::default(), + true, + )); + entity + } + + fn find(&self, entity: EntityRef) -> Option { + self.entities.iter().position(|(e, ..)| *e == entity) + } + } + + impl Host for FakeHost { + fn entity_valid(&self, entity: EntityRef) -> bool { + self.find(entity).is_some() + } + fn entity_class(&self, entity: EntityRef) -> String { + self.find(entity) + .map(|i| self.entities[i].1.clone()) + .unwrap_or_default() + } + fn entity_model(&self, _entity: EntityRef) -> Option { + Some("models/props_c17/woodcrate001a.mdl".into()) + } + fn entity_pos(&self, entity: EntityRef) -> Option { + self.find(entity).map(|i| self.entities[i].2) + } + fn set_entity_pos(&mut self, entity: EntityRef, pos: LuaVector) -> bool { + match self.find(entity) { + Some(i) => { + self.entities[i].2 = pos; + true + } + None => false, + } + } + fn entity_angles(&self, entity: EntityRef) -> Option { + self.find(entity).map(|i| self.entities[i].3) + } + fn set_entity_angles(&mut self, entity: EntityRef, angles: LuaAngle) -> bool { + match self.find(entity) { + Some(i) => { + self.entities[i].3 = angles; + true + } + None => false, + } + } + fn entity_movable(&self, entity: EntityRef) -> bool { + self.find(entity) + .map(|i| self.entities[i].4) + .unwrap_or(false) + } + fn entities_all(&self) -> Vec { + self.alive.clone() + } + fn entities_in_sphere(&self, center: LuaVector, radius: f32) -> Vec { + self.entities + .iter() + .filter(|(_, _, pos, _, _)| distance(*pos, center) <= radius) + .map(|(e, ..)| *e) + .collect() + } + fn spawn_entity( + &mut self, + _classname: &str, + pos: LuaVector, + angles: LuaAngle, + ) -> Option { + let entity = self.next(); + let i = self.find(entity)?; + self.entities[i].2 = pos; + self.entities[i].3 = angles; + Some(entity) + } + fn remove_entity(&mut self, entity: EntityRef) -> bool { + let Some(i) = self.find(entity) else { + return false; + }; + self.entities.remove(i); + self.alive.retain(|e| *e != entity); + true + } + fn player(&self) -> Option { + self.alive.first().copied() + } + fn player_name(&self) -> String { + "player".into() + } + } + + fn runtime() -> (Lua, Rc>) { + let lua = Lua::new(); + gmod::install(&lua).expect("типы"); + let host = Rc::new(RefCell::new(FakeHost::default())); + install(&lua, host.clone()).expect("ents"); + // Две сущности, чтобы `Entity(1)` и `Entity(2)` различались. + host.borrow_mut().next(); + host.borrow_mut().next(); + (lua, host) + } + + /// Читает вектор из глобальной переменной Lua. + fn read_vector(lua: &Lua, name: &str) -> LuaVector { + let value = lua.globals().get::(name).expect("переменная"); + *value + .as_userdata() + .expect("userdata") + .borrow::() + .unwrap() + } + + #[test] + fn entity_resolves_and_reads_position() { + let (lua, host) = runtime(); + host.borrow_mut().set_entity_pos( + EntityRef { + slot: 0, + generation: 1, + }, + LuaVector::new(1.0, 2.0, 3.0), + ); + lua.load( + r#" + ent = Entity(1) + pos = ent:GetPos() + cls = ent:GetClass() + "#, + ) + .exec() + .expect("скрипт"); + let pos = read_vector(&lua, "pos"); + assert_eq!(pos, LuaVector::new(1.0, 2.0, 3.0)); + let cls: String = mlua::FromLua::from_lua(lua.globals().get("cls").unwrap(), &lua).unwrap(); + assert_eq!(cls, "prop_physics"); + } + + #[test] + fn invalid_index_returns_invalid_entity_not_error() { + let (lua, _) = runtime(); + // Обычный скриптовый приём — проверка через IsValid(); падать тут нельзя. + lua.load("ent = Entity(9999)\nvalid = ent:IsValid()") + .exec() + .expect("скрипт"); + let valid: bool = + mlua::FromLua::from_lua(lua.globals().get("valid").unwrap(), &lua).unwrap(); + assert!( + !valid, + "несуществующий индекс обязан давать невалидную сущность" + ); + } + + #[test] + fn entity_0_is_the_player() { + let (lua, host) = runtime(); + let expected = host.borrow().player().expect("игрок"); + lua.load("p = Entity(0)\ncls = p:GetClass()") + .exec() + .expect("скрипт"); + assert_eq!(expected.slot, 0); + } + + #[test] + fn setpos_moves_the_entity_in_the_host() { + let (lua, host) = runtime(); + lua.load("Entity(1):SetPos(Vector(10, 20, 30))") + .exec() + .expect("скрипт"); + let pos = host + .borrow() + .entity_pos(EntityRef { + slot: 0, + generation: 1, + }) + .expect("сущность жива"); + assert_eq!(pos, LuaVector::new(10.0, 20.0, 30.0)); + } + + #[test] + fn removed_entity_becomes_invalid() { + let (lua, _) = runtime(); + lua.load("ent = Entity(1)\nent:Remove()\nafter = ent:IsValid()") + .exec() + .expect("скрипт"); + let after: bool = + mlua::FromLua::from_lua(lua.globals().get("after").unwrap(), &lua).unwrap(); + assert!(!after, "удалённая сущность обязана протухать"); + } + + #[test] + fn ents_lists_and_creates() { + let (lua, _) = runtime(); + lua.load( + r#" + all = ents.GetAll() + count = #all + made = ents.Create("prop_physics", Vector(0,0,10), Angle(0,0,0)) + madeClass = made:GetClass() + near = ents.FindInSphere(Vector(0,0,10), 50) + nearCount = #near + byClass = ents.GetByClass("prop_physics") + "#, + ) + .exec() + .expect("скрипт"); + let count: usize = + mlua::FromLua::from_lua(lua.globals().get("count").unwrap(), &lua).unwrap(); + assert_eq!(count, 2, "в мире две сущности"); + let made_class: String = + mlua::FromLua::from_lua(lua.globals().get("madeClass").unwrap(), &lua) + .expect("класс созданной сущности"); + assert_eq!( + made_class, "prop_physics", + "ents.Create обязан вернуть рабочую сущность" + ); + let near_count: usize = + mlua::FromLua::from_lua(lua.globals().get("nearCount").unwrap(), &lua).unwrap(); + assert!( + near_count >= 1, + "сфера вокруг (0,0,10) обязана что-то найти" + ); + } + + #[test] + fn panicking_host_does_not_crash_the_client() { + struct Broken; + impl Host for Broken { + fn entity_valid(&self, _e: EntityRef) -> bool { + panic!("движок сломан") + } + fn entity_class(&self, _e: EntityRef) -> String { + panic!("движок сломан") + } + fn entity_model(&self, _e: EntityRef) -> Option { + None + } + fn entity_pos(&self, _e: EntityRef) -> Option { + panic!("движок сломан") + } + fn set_entity_pos(&mut self, _e: EntityRef, _p: LuaVector) -> bool { + false + } + fn entity_angles(&self, _e: EntityRef) -> Option { + None + } + fn set_entity_angles(&mut self, _e: EntityRef, _a: LuaAngle) -> bool { + false + } + fn entity_movable(&self, _e: EntityRef) -> bool { + false + } + fn entities_all(&self) -> Vec { + vec![EntityRef { + slot: 0, + generation: 1, + }] + } + fn entities_in_sphere(&self, _c: LuaVector, _r: f32) -> Vec { + Vec::new() + } + fn spawn_entity(&mut self, _c: &str, _p: LuaVector, _a: LuaAngle) -> Option { + None + } + fn remove_entity(&mut self, _e: EntityRef) -> bool { + false + } + fn player(&self) -> Option { + None + } + fn player_name(&self) -> String { + "player".into() + } + } + let lua = Lua::new(); + gmod::install(&lua).expect("типы"); + let host: HostRef = Rc::new(RefCell::new(Broken)); + install(&lua, host).expect("ents"); + // Хост падает на каждом вызове — скрипт обязан получить «невалидно». + lua.load("ent = Entity(1)\nvalid = ent:IsValid()") + .exec() + .expect("скрипт не должен падать"); + let valid: bool = + mlua::FromLua::from_lua(lua.globals().get("valid").unwrap(), &lua).unwrap(); + assert!( + !valid, + "упавший хост обязан давать «невалидно», а не ронять клиент" + ); + } +} diff --git a/crates/lua/src/gmod.rs b/crates/lua/src/gmod.rs index 86cd05b..9d5bf0c 100644 --- a/crates/lua/src/gmod.rs +++ b/crates/lua/src/gmod.rs @@ -262,10 +262,44 @@ impl UserData for LuaColor { } } -/// Копия вектора из значения Lua. +/// Реализация `FromLua` для наших типов. /// -/// В mlua 0.11 для userdata нет автоматического `FromLua`, только -/// `AnyUserData::borrow`, поэтому чтение всегда идёт через него. +/// В mlua 0.11 для userdata **нет** автоматического `FromLua`: без этой +/// реализации ни одна функция не может принять `Vector` аргументом, и любая +/// привязка вроде `ent:SetPos(v)` не собирается. Пишем один раз на тип — +/// дальше работает везде, включая кортежи аргументов. +macro_rules! impl_from_lua_userdata { + ($ty:ty, $name:literal) => { + impl mlua::FromLua for $ty { + fn from_lua(value: mlua::Value, _lua: &Lua) -> mlua::Result { + let ud = + value + .as_userdata() + .ok_or_else(|| mlua::Error::FromLuaConversionError { + from: value.type_name(), + to: $name.to_string(), + message: Some( + "ожидался userdata, созданный конструктором GMod".to_string(), + ), + })?; + // Копия, а не ссылка: значение не должно жить дольше вызова. + let value = + *ud.borrow::<$ty>() + .map_err(|_| mlua::Error::FromLuaConversionError { + from: "userdata", + to: $name.to_string(), + message: Some("тип не совпадает".to_string()), + })?; + Ok(value) + } + } + }; +} + +impl_from_lua_userdata!(LuaVector, "Vector"); +impl_from_lua_userdata!(LuaAngle, "Angle"); +impl_from_lua_userdata!(LuaColor, "Color"); + /// Число из значения Lua: и `1.5` (`Number`), и `1` (`Integer`). /// /// Важно: `Value::as_number()` в mlua 0.11 различает **только** `Number` и @@ -286,6 +320,7 @@ fn value_number(lua: &Lua, value: &Value) -> Option { } } +/// Копия вектора из значения Lua. fn borrow_vector(value: &Value) -> Option { Some(*value.as_userdata()?.borrow::().ok()?) } diff --git a/crates/lua/src/lib.rs b/crates/lua/src/lib.rs index 67b9975..75fbdb0 100644 --- a/crates/lua/src/lib.rs +++ b/crates/lua/src/lib.rs @@ -3,6 +3,7 @@ //! v0: `print`/`Msg`, `hook.*`, `timer.Simple`, `concommand.*` (реестр), //! вывод в буфер для HUD/консоли. Игровые библиотеки — следующие итерации. +pub mod ents; pub mod gmod; use mlua::{Function, Lua, MultiValue, RegistryKey, Table, Value}; @@ -53,6 +54,28 @@ impl LuaRuntime { Ok(()) } + /// Подключает игровой мир: появляются `Entity()`, `ents.*`, `player`. + /// + /// Отдельно от `new()`, потому что хост всегда приходит снаружи — движок + /// не должен зависеть от Lua-слоя и наоборот. + pub fn attach_host(&self, host: ents::HostRef) -> Result<(), LuaError> { + ents::install(&self.lua, host)?; + Ok(()) + } + + /// Выполняет короткий скрипт и возвращает первую строку вывода. + /// + /// Нужна клиенту для проверки моста: «Lua видит 1227 сущностей» — + /// измерение, а «мост подключён» — утверждение. + pub fn probe(&self, source: &str) -> Result, LuaError> { + let chunk = self.lua.load(source).set_name("probe"); + match chunk.eval::() { + Ok(value) => Ok(Some(value)), + // Не-строка — норма для Lua; приводим к строке через tostring. + Err(_) => Ok(None), + } + } + /// Устанавливает имя карты для `game.GetMap()`. pub fn set_map(&self, map: &str) -> Result<(), LuaError> { let game: Table = self.lua.globals().get("game")?; diff --git a/docs/PARITY.md b/docs/PARITY.md index af785e6..67f4591 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -511,3 +511,57 @@ Rapier обслуживает только **динамические тела** - Связка с движком: `glua` не должен зависеть от `engine`, обмен — через колбэки, которые регистрирует клиент. - `Vector:Angle()`, `Vector:Distance` между сущностями. + +## Часть D2. Мост Lua к движку (сделано) + +### D2.1. Устройство + +`glua` **не зависит** от `engine`. Движок отдаёт поведение через трейт +`glua::ents::Host`, реализация которого живёт в клиенте +(`crates/client/src/host.rs`). Иначе получился бы цикл зависимостей и +Lua-слой нельзя было бы тестировать в одиночку. + +Мир переведён в `Rc>`: скрипт обращается к нему из хуков в +любой момент, пока кадр держит заимствование. Все обращения идут через +`try_borrow`, потому что `borrow` при пересечении даёт `panic` от +`RefCell`, а падать из-за безобидного скрипта клиент не должен. + +### D2.2. Что доступно скрипту + +`Entity(n)`, `ents.GetAll/GetByClass/FindInSphere/Create`, +`player`, а также `GetPos/SetPos/GetAngles/SetAngles/GetClass/GetModel/ +GetVelocity/IsValid/Remove/GetName`. + +`Entity(0)` — игрок, `Entity(9999)` — невалидная сущность, а не ошибка: +скрипты routinely проверяют результат через `IsValid()`. + +### D2.3. Найденный рассинхрон (важный) + +`move_prop` обновлял позу экземпляра пропа, но **не** запись в пуле +сущностей. Проверено измерением: `ents.GetByClass('prop_physics'):GetPos()` +давал `z = -64`, пока физика уже опустила проп на `z = -84`. «Данные» и +«картинка» разошлись, и заметить это можно было только глазами. + +Теперь поза пишется в оба места всегда, даже если геометрию пересчитать +нечем (нет модели): проп без модели всё равно стоит правильно, он просто не +рисуется. Возвращаемое значение означает именно «геометрия пересчитана». + +Проверка после правки: Lua читает `z = -84.15848`, физика даёт +`-84.15847778320313` — совпадение до последнего бита. + +### D2.4. Границы + +- `SetModel`, `SetColor`, `PhysicsInit` принимаются и ничего не делают: + принимать вызов нужно, чтобы скрипты не падали, но обещать нечего. +- `GetVelocity` делит скорость тела на шаг 1/60, а не читает «скорость за + последний кадр»: иначе скрипт получил бы юниты/кадр вместо юнитов/с. +- Созданная из Lua сущность не получает модель и тело физики. +- Игрок — `None`, пока клиент его не зарегистрировал. + +### D2.5. Что осталось по части D2 + +- `Vector:Distance`, `Vector:Angle`, `GetModel`-обратная привязка. +- `Entity:GetTable`/`SetField` и сетевые функции. +- Регистрация сущности игрока, чтобы `Entity(0)` и `player` совпали. +- `ents.Create` должен уметь заводить тело физики для `prop_physics`. +- Подключение файлов `gamemodes/sandbox` как есть.