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:
parent
b9b03abe3b
commit
b991cc59f1
9 changed files with 1171 additions and 41 deletions
207
crates/client/src/host.rs
Normal file
207
crates/client/src/host.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
mod convars;
|
||||
mod doctor;
|
||||
mod host;
|
||||
mod lua_cmd;
|
||||
mod map;
|
||||
mod menu;
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
675
crates/lua/src/ents.rs
Normal 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,
|
||||
"упавший хост обязан давать «невалидно», а не ронять клиент"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()?)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")?;
|
||||
|
|
|
|||
|
|
@ -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` как есть.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue