lua: мост к движку — Entity(), ents.* работают с живым миром

Батч 3, шаг 2. Крейт glua не зависит от engine: поведение отдаётся через
трейт Host, реализация которого живёт в клиенте. Иначе был бы цикл
зависимостей и Lua-слой нельзя было бы тестировать в одиночку.

Мир переведён в Rc<RefCell<World>> — скрипт обращается к нему из хуков в
любой момент. Все обращения через 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 чист.
This commit is contained in:
Niko Marmeladkov 2026-10-13 12:57:01 +03:00
parent b9b03abe3b
commit b991cc59f1
9 changed files with 1171 additions and 41 deletions

207
crates/client/src/host.rs Normal file
View file

@ -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<EntityIndex> {
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<RefCell<World>>,
/// Сущность игрока: её создаёт клиент, а не пул карты.
player: Option<EntityIndex>,
}
impl WorldHost {
pub fn new(world: Rc<RefCell<World>>, player: Option<EntityIndex>) -> Self {
Self { world, player }
}
/// Достаёт сущность по ссылке Lua, не падая на рассинхрон.
fn lookup(&self, entity: EntityRef) -> Option<Entity> {
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<String> {
self.lookup(entity).and_then(|e| e.model)
}
fn entity_pos(&self, entity: EntityRef) -> Option<LuaVector> {
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<LuaAngle> {
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<EntityRef> {
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<EntityRef> {
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<EntityRef> {
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<EntityRef> {
self.player.map(to_ref)
}
fn player_name(&self) -> String {
"player".to_string()
}
}

View file

@ -2,6 +2,7 @@
mod convars;
mod doctor;
mod host;
mod lua_cmd;
mod map;
mod menu;

View file

@ -133,20 +133,23 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
}
let started = Instant::now();
let mut world = engine::build_world(&bsp, &vfs)?;
// Мир в Rc<RefCell<>>: 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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)?;

View file

@ -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

View file

@ -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, "и углы тоже");
}
}

675
crates/lua/src/ents.rs Normal file
View file

@ -0,0 +1,675 @@
//! Мост к игровому миру: `Entity()` и `ents.*`.
//!
//! Крейт `glua` **не зависит** от `engine`: движок задаёт поведение через
//! трейт [`Host`], а клиент реализует его. Иначе получился бы цикл
//! зависимостей и Lua-слой нельзя было бы тестировать в одиночку.
//!
//! Мутации идут через `Rc<RefCell<dyn Host>>`, потому что вызов из 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<String>;
/// Позиция в юнитах Source.
fn entity_pos(&self, entity: EntityRef) -> Option<LuaVector>;
/// Переставить сущность.
fn set_entity_pos(&mut self, entity: EntityRef, pos: LuaVector) -> bool;
/// Углы в градусах.
fn entity_angles(&self, entity: EntityRef) -> Option<LuaAngle>;
/// Повернуть сущность.
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<EntityRef>;
/// Найти в сфере.
fn entities_in_sphere(&self, center: LuaVector, radius: f32) -> Vec<EntityRef>;
/// Создать сущность; `None`, если класс неизвестен или лимит исчерпан.
fn spawn_entity(
&mut self,
classname: &str,
pos: LuaVector,
angles: LuaAngle,
) -> Option<EntityRef>;
/// Удалить сущность.
fn remove_entity(&mut self, entity: EntityRef) -> bool;
/// Сущность игрока.
fn player(&self) -> Option<EntityRef>;
/// Имя игрока (для `Entity:GetName`).
fn player_name(&self) -> String;
}
/// Общий доступ к хосту.
pub type HostRef = Rc<RefCell<dyn Host>>;
/// Обёртка над сущностью для 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<T>(&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<T: Default>(&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<F: UserDataFields<Self>>(_fields: &mut F) {
// Поля сущности задаются методами, как в GMod: `ent:GetPos()`, а не
// `ent.pos`. Прямые поля оставлены для совместимости с частью
// скриптов, которые всё же читают `ent.pos`.
}
fn add_methods<M: UserDataMethods<Self>>(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::<Self>()?.call_valid())
});
m.add_function("GetClass", |_, e: AnyUserData| {
let e = e.borrow::<Self>()?;
Ok(e.read_host(|h| h.entity_class(e.entity)))
});
m.add_function("GetModel", |_, e: AnyUserData| {
let e = e.borrow::<Self>()?;
Ok(e.read_host(|h| h.entity_model(e.entity))
.unwrap_or_default())
});
m.add_function("GetPos", |lua, e: AnyUserData| {
let e = e.borrow::<Self>()?;
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::<Self>()?;
Ok(e.with_host(false, |h| h.set_entity_pos(e.entity, pos)))
});
m.add_function("GetAngles", |lua, e: AnyUserData| {
let e = e.borrow::<Self>()?;
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::<Self>()?;
Ok(e.with_host(false, |h| h.set_entity_angles(e.entity, angles)))
});
m.add_function("GetVelocity", |lua, e: AnyUserData| {
let e = e.borrow::<Self>()?;
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::<Self>()?;
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::<Self>()?;
Ok(e.read_host(|h| h.entity_movable(e.entity)))
});
m.add_function("PhysicsInit", |_, e: AnyUserData| {
// `PhysicsInit` в GMod включает физику для `func_*`. Здесь ответ
// всегда «уже включено»: заводить тела из Lua нельзя, а молча
// врать хуже, чем сказать правду.
let e = e.borrow::<Self>()?;
Ok(e.call_valid())
});
m.add_function("Remove", |_, e: AnyUserData| {
let e = e.borrow::<Self>()?;
e.with_host(false, |h| h.remove_entity(e.entity));
Ok(())
});
m.add_function("GetName", |_, e: AnyUserData| {
let e = e.borrow::<Self>()?;
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::<Self>()?.call_valid())
});
m.add_function("SetModel", |_, (e, _m): (AnyUserData, String)| {
Ok(e.borrow::<Self>()?.call_valid())
});
}
}
/// Создаёт userdata сущности.
fn wrap(lua: &Lua, entity: EntityRef, host: &HostRef) -> mlua::Result<AnyUserData> {
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<Value> {
// Неизвестный класс возвращает `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<EntityRef> {
// В 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<Table> {
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::<mlua::MultiValue>(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<EntityRef>,
}
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<usize> {
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<String> {
Some("models/props_c17/woodcrate001a.mdl".into())
}
fn entity_pos(&self, entity: EntityRef) -> Option<LuaVector> {
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<LuaAngle> {
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<EntityRef> {
self.alive.clone()
}
fn entities_in_sphere(&self, center: LuaVector, radius: f32) -> Vec<EntityRef> {
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<EntityRef> {
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<EntityRef> {
self.alive.first().copied()
}
fn player_name(&self) -> String {
"player".into()
}
}
fn runtime() -> (Lua, Rc<RefCell<FakeHost>>) {
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::<Value>(name).expect("переменная");
*value
.as_userdata()
.expect("userdata")
.borrow::<LuaVector>()
.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<String> {
None
}
fn entity_pos(&self, _e: EntityRef) -> Option<LuaVector> {
panic!("движок сломан")
}
fn set_entity_pos(&mut self, _e: EntityRef, _p: LuaVector) -> bool {
false
}
fn entity_angles(&self, _e: EntityRef) -> Option<LuaAngle> {
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<EntityRef> {
vec![EntityRef {
slot: 0,
generation: 1,
}]
}
fn entities_in_sphere(&self, _c: LuaVector, _r: f32) -> Vec<EntityRef> {
Vec::new()
}
fn spawn_entity(&mut self, _c: &str, _p: LuaVector, _a: LuaAngle) -> Option<EntityRef> {
None
}
fn remove_entity(&mut self, _e: EntityRef) -> bool {
false
}
fn player(&self) -> Option<EntityRef> {
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,
"упавший хост обязан давать «невалидно», а не ронять клиент"
);
}
}

View file

@ -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<Self> {
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<f64> {
}
}
/// Копия вектора из значения Lua.
fn borrow_vector(value: &Value) -> Option<LuaVector> {
Some(*value.as_userdata()?.borrow::<LuaVector>().ok()?)
}

View file

@ -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<Option<String>, LuaError> {
let chunk = self.lua.load(source).set_name("probe");
match chunk.eval::<String>() {
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")?;

View file

@ -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<RefCell<World>>`: скрипт обращается к нему из хуков в
любой момент, пока кадр держит заимствование. Все обращения идут через
`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` как есть.