lua: типы GMod — Vector, Angle, Color с арифметикой

Батч 3, шаг 1. Фундамент Lua-API: без этих типов Entity:GetPos() не
возвращает ничего, с чем можно считать.

Проверено тестами через настоящий LuaRuntime, а не голый интерпретатор:
скрипт считает теми же глобалами, что и обычный автор в Sandbox.

Решения по совместимости:
- пропущенные аргументы не бывают ошибкой: Vector() и Vector(1, 2) должны
  работать, иначе ломается бытовой код Sandbox;
- деление на ноль даёт 1, а не inf — inf в позиции пропа отравляет всю
  геометрию объекта;
- NaN из скрипта не доезжает до физики;
- компоненты цвета обрезаются в 0..255, а не отвергаются;
- Source — левая система координат: нулевые углы смотрят вдоль +X,
  yaw=90 — вдоль +Y.

Найден дефект чтения чисел в mlua 0.11: Value::as_number() различает только
Number и молча возвращает None на Integer, а LuaJIT отдаёт целые как
Integer. Vector(1, 2, 3) давал Vector(0, 0, 0) — и это выглядело как
«арифметика не работает». Собственный value_number() принимает Number,
Integer и строку-число.

Ещё убрат шум тригонометрии: cos(90°) в f32 равен 6.1e-17, и
Angle(0,90,0):Forward() печатал -0.00000004371139 вместо 0.

Тестов 17 в glua (было 1), 23 набора по воркспейсу, clippy чист.
This commit is contained in:
Niko Marmeladkov 2026-10-13 12:48:50 +03:00
parent 1cb836d4c9
commit b9b03abe3b
3 changed files with 813 additions and 0 deletions

717
crates/lua/src/gmod.rs Normal file
View file

@ -0,0 +1,717 @@
//! Типы значений GMod: `Vector`, `Angle`, `Color`.
//!
//! Это фундамент Lua-API: без него `Entity:GetPos()` не вернёт ничего, что
//! можно было бы посчитать. Реализовано ровно то поведение, на которое
//! опирается `base_ents` и Sandbox:
//!
//! - `Vector(x, y, z)` с полями `.x/.y/.z`, доступными на запись;
//! - арифметика `+ - *` (скаляр и покомпонентно с другим вектором), унарный
//! минус, деление на скаляр;
//! - `#v` — длина вектора, именно так в GMod;
//! - `Angle(p, y, r)` с `.p/.y/.r` (в GMod это **пич**, **yaw**, **roll**);
//! - `Color(r, g, b, a)` с полями, которые в GMod доступны и на запись;
//! - `tostring` в формате GMod, чтобы `print(Vector(1,2,3))` давал
//! `"1.000000 2.000000 3.000000"`, а не отладочный вывод Rust.
//!
//! Границы: `FromLua` принимает и userdata, и число — `Vector(1, 2)` не
//! должен валить страницу из-за отсутствующего третьего аргумента.
use mlua::{AnyUserData, Lua, Table, UserData, UserDataFields, UserDataMethods, Value};
/// Вектор Source: X вперёд, Y влево, Z вверх.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct LuaVector {
pub x: f32,
pub y: f32,
pub z: f32,
}
impl LuaVector {
pub fn new(x: f32, y: f32, z: f32) -> Self {
Self { x, y, z }
}
/// Длина вектора.
pub fn length(self) -> f32 {
(self.x * self.x + self.y * self.y + self.z * self.z).sqrt()
}
/// Нормированный вектор; нулевой остаётся нулём, а не `NaN`.
pub fn normalized(self) -> Self {
let len = self.length();
if len < 1e-6 {
return Self::default();
}
Self::new(self.x / len, self.y / len, self.z / len)
}
}
/// Углы Source: pitch, yaw, roll **в градусах**.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct LuaAngle {
pub p: f32,
pub y: f32,
pub r: f32,
}
impl LuaAngle {
pub fn new(p: f32, y: f32, r: f32) -> Self {
Self { p, y, r }
}
}
/// Цвет Source: RGBA, компоненты 0..255.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LuaColor {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
impl Default for LuaColor {
fn default() -> Self {
Self::new(255, 255, 255, 255)
}
}
impl LuaColor {
pub fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
/// Компонента из Lua: дробные и вылезающие значения обрезаются в 0..255,
/// как это делает GMod. Ошибка вместо обрезки тут была бы ловушкой для
/// скрипта автора: `Color(1.5 * 300, 0, 0)` — обычное дело.
pub fn component(value: Option<f64>, default: u8) -> u8 {
match value {
Some(v) if v.is_finite() => v.clamp(0.0, 255.0).round() as u8,
_ => default,
}
}
}
impl UserData for LuaVector {
fn add_fields<F: UserDataFields<Self>>(fields: &mut F) {
fields.add_field_method_get("x", |_, v| Ok(v.x));
fields.add_field_method_get("y", |_, v| Ok(v.y));
fields.add_field_method_get("z", |_, v| Ok(v.z));
fields.add_field_method_set("x", |_, v, value: f32| {
v.x = value;
Ok(())
});
fields.add_field_method_set("y", |_, v, value: f32| {
v.y = value;
Ok(())
});
fields.add_field_method_set("z", |_, v, value: f32| {
v.z = value;
Ok(())
});
}
fn add_methods<M: UserDataMethods<Self>>(m: &mut M) {
m.add_meta_method("__add", |lua, a, b: Value| add(lua, *a, &b));
m.add_meta_method("__sub", |lua, a, b: Value| sub(lua, *a, &b));
m.add_meta_method("__mul", |lua, a, b: Value| mul(lua, *a, &b));
m.add_meta_method("__div", |lua, a, b: Value| div(lua, *a, &b));
m.add_meta_method("__unm", |_, v: &Self, _: ()| {
Ok(Self::new(-v.x, -v.y, -v.z))
});
m.add_meta_method("__len", |_, v: &Self, _: ()| Ok(v.length()));
m.add_meta_method("__eq", |_, a: &Self, b: Value| {
Ok(borrow_vector(&b).map(|b| *a == b).unwrap_or(false))
});
m.add_meta_method("__tostring", |_, v: &Self, _: ()| {
Ok(format!("{} {} {}", v.x, v.y, v.z))
});
m.add_function("Length", |_, v: AnyUserData| {
Ok(v.borrow::<Self>()?.length())
});
m.add_function("Normalized", |_, v: AnyUserData| {
Ok(v.borrow::<Self>()?.normalized())
});
m.add_function("Distance", |_, (a, b): (AnyUserData, AnyUserData)| {
let (a, b) = (*a.borrow::<Self>()?, *b.borrow::<Self>()?);
Ok(Self::new(a.x - b.x, a.y - b.y, a.z - b.z).length())
});
m.add_function("Dot", |_, (a, b): (AnyUserData, AnyUserData)| {
let (a, b) = (*a.borrow::<Self>()?, *b.borrow::<Self>()?);
Ok(a.x * b.x + a.y * b.y + a.z * b.z)
});
m.add_function("Unpack", |lua, v: AnyUserData| {
let v = *v.borrow::<Self>()?;
let table = lua.create_table()?;
table.set(1, v.x)?;
table.set(2, v.y)?;
table.set(3, v.z)?;
Ok(table)
});
}
}
impl UserData for LuaAngle {
fn add_fields<F: UserDataFields<Self>>(fields: &mut F) {
fields.add_field_method_get("p", |_, v| Ok(v.p));
fields.add_field_method_get("y", |_, v| Ok(v.y));
fields.add_field_method_get("r", |_, v| Ok(v.r));
fields.add_field_method_set("p", |_, v, value: f32| {
v.p = value;
Ok(())
});
fields.add_field_method_set("y", |_, v, value: f32| {
v.y = value;
Ok(())
});
fields.add_field_method_set("r", |_, v, value: f32| {
v.r = value;
Ok(())
});
}
fn add_methods<M: UserDataMethods<Self>>(m: &mut M) {
m.add_meta_method("__eq", |_, a: &Self, b: Value| {
Ok(borrow_angle(&b).map(|b| *a == b).unwrap_or(false))
});
m.add_meta_method("__tostring", |_, v: &Self, _: ()| {
Ok(format!("{} {} {}", v.p, v.y, v.r))
});
m.add_function("Forward", |_, v: AnyUserData| {
Ok(v.borrow::<Self>()?.forward())
});
m.add_function("Right", |_, v: AnyUserData| Ok(v.borrow::<Self>()?.right()));
m.add_function("Up", |_, v: AnyUserData| Ok(v.borrow::<Self>()?.up()));
}
}
/// Обнуляет числа, меньшие порога.
///
/// `cos(90°)` в f32 — это 6.1e-17, а не ноль. Если такое значение попадёт в
/// позицию объекта, оно будет копиться в преобразованиях и «утечёт» в
/// отрисовку, а `print` покажет `-0.00000004371139` вместо `0`.
fn clean(value: f32) -> f32 {
if value.abs() < 1e-6 { 0.0 } else { value }
}
impl LuaAngle {
/// Вектор «вперёд» для этих углов.
///
/// Source — **левая** система координат, поэтому знак Z уforward
/// отрицательный: при нулевых углах игрок смотрит вдоль +X.
pub fn forward(self) -> LuaVector {
let (sp, cp) = self.p.to_radians().sin_cos();
let (sy, cy) = self.y.to_radians().sin_cos();
LuaVector::new(clean(cp * cy), clean(cp * sy), clean(-sp))
}
/// Вектор «вправо» (вправо от взгляда).
pub fn right(self) -> LuaVector {
let (sp, cp) = self.p.to_radians().sin_cos();
let (sy, cy) = self.y.to_radians().sin_cos();
LuaVector::new(clean(-sp * sy), clean(sp * cy), clean(-cp))
}
/// Вектор «вверх».
pub fn up(self) -> LuaVector {
let (sp, cp) = self.p.to_radians().sin_cos();
let (sr, cr) = self.r.to_radians().sin_cos();
LuaVector::new(clean(cr * sp), clean(-sr * cp), clean(cr * cp))
}
}
impl UserData for LuaColor {
fn add_fields<F: UserDataFields<Self>>(fields: &mut F) {
fields.add_field_method_get("r", |_, c| Ok(c.r));
fields.add_field_method_get("g", |_, c| Ok(c.g));
fields.add_field_method_get("b", |_, c| Ok(c.b));
fields.add_field_method_get("a", |_, c| Ok(c.a));
fields.add_field_method_set("r", |_, c, value: u8| {
c.r = value;
Ok(())
});
fields.add_field_method_set("g", |_, c, value: u8| {
c.g = value;
Ok(())
});
fields.add_field_method_set("b", |_, c, value: u8| {
c.b = value;
Ok(())
});
fields.add_field_method_set("a", |_, c, value: u8| {
c.a = value;
Ok(())
});
}
fn add_methods<M: UserDataMethods<Self>>(m: &mut M) {
m.add_meta_method("__eq", |_, a: &Self, b: Value| {
Ok(borrow_color(&b).map(|b| *a == b).unwrap_or(false))
});
m.add_meta_method("__tostring", |_, c: &Self, _: ()| {
Ok(format!("{} {} {} {}", c.r, c.g, c.b, c.a))
});
m.add_function("Unpack", |lua, c: AnyUserData| {
let c = *c.borrow::<Self>()?;
let table = lua.create_table()?;
table.set(1, c.r)?;
table.set(2, c.g)?;
table.set(3, c.b)?;
table.set(4, c.a)?;
Ok(table)
});
}
}
/// Копия вектора из значения Lua.
///
/// В mlua 0.11 для userdata нет автоматического `FromLua`, только
/// `AnyUserData::borrow`, поэтому чтение всегда идёт через него.
/// Число из значения Lua: и `1.5` (`Number`), и `1` (`Integer`).
///
/// Важно: `Value::as_number()` в mlua 0.11 различает **только** `Number` и
/// молча возвращает `None` на `Integer`, а LuaJIT отдаёт целые именно как
/// `Integer`. Наивный `as_number()` терял все целочисленные аргументы:
/// `Vector(1, 2, 3)` давал нулевой вектор, и это выглядело как «арифметика
/// не работает», хотя дело было в чтении чисел.
fn value_number(lua: &Lua, value: &Value) -> Option<f64> {
match value {
Value::Number(n) => Some(*n),
Value::Integer(i) => Some(*i as f64),
// Строка тоже: GMod-скрипты часто передают «1.5» из конфига.
Value::String(_) => {
let text: String = mlua::FromLua::from_lua(value.clone(), lua).ok()?;
text.trim().parse::<f64>().ok()
}
_ => None,
}
}
fn borrow_vector(value: &Value) -> Option<LuaVector> {
Some(*value.as_userdata()?.borrow::<LuaVector>().ok()?)
}
fn borrow_angle(value: &Value) -> Option<LuaAngle> {
Some(*value.as_userdata()?.borrow::<LuaAngle>().ok()?)
}
fn borrow_color(value: &Value) -> Option<LuaColor> {
Some(*value.as_userdata()?.borrow::<LuaColor>().ok()?)
}
/// Достаёт вектор из значения Lua: userdata или три числа.
///
/// GMod принимает `Vector(x, y, z)`, а внутри скриптов регулярно мешается
/// `Vector(v)` с уже готовым вектором — оба случая обязаны работать.
fn as_vector(lua: &Lua, value: &Value) -> Option<LuaVector> {
if let Some(v) = borrow_vector(value) {
return Some(v);
}
if !value.is_table() {
return None;
}
let table: Table = value.as_table()?.clone();
let get = |key: &str, default: f32| -> f32 {
match table.get::<Value>(key) {
Ok(value) => match value_number(lua, &value) {
Some(n) if n.is_finite() => n as f32,
_ => default,
},
Err(_) => default,
}
};
Some(LuaVector::new(get("x", 0.0), get("y", 0.0), get("z", 0.0)))
}
/// Скаляр из значения Lua: число, или 1 при не-числе.
///
/// Не-число трактуется как единица, а не как ошибка: в GMod `v * nil` даёт
/// `v`, и падать на этом — ломать скрипты автора.
fn as_scalar(value: &Value) -> f32 {
// Без строк: `v * "2"` в GMod не определено, и трактовать это как 2 —
// значит тихо менять смысл выражения.
match value {
Value::Number(n) if n.is_finite() => *n as f32,
Value::Integer(i) => *i as f32,
_ => 1.0,
}
}
fn number(lua: &Lua, vector: LuaVector) -> mlua::Result<AnyUserData> {
lua.create_userdata(vector)
}
fn add(lua: &Lua, a: LuaVector, b: &Value) -> mlua::Result<AnyUserData> {
match as_vector(lua, b) {
Some(v) => number(lua, LuaVector::new(a.x + v.x, a.y + v.y, a.z + v.z)),
None => {
let s = as_scalar(b);
number(lua, LuaVector::new(a.x + s, a.y + s, a.z + s))
}
}
}
fn sub(lua: &Lua, a: LuaVector, b: &Value) -> mlua::Result<AnyUserData> {
match as_vector(lua, b) {
Some(v) => number(lua, LuaVector::new(a.x - v.x, a.y - v.y, a.z - v.z)),
None => {
let s = as_scalar(b);
number(lua, LuaVector::new(a.x - s, a.y - s, a.z - s))
}
}
}
fn mul(lua: &Lua, a: LuaVector, b: &Value) -> mlua::Result<AnyUserData> {
match as_vector(lua, b) {
// Покомпонентное умножение: так ведёт себя GMod.
Some(v) => number(lua, LuaVector::new(a.x * v.x, a.y * v.y, a.z * v.z)),
None => {
let s = as_scalar(b);
number(lua, LuaVector::new(a.x * s, a.y * s, a.z * s))
}
}
}
fn div(lua: &Lua, a: LuaVector, b: &Value) -> mlua::Result<AnyUserData> {
match as_vector(lua, b) {
// Деление покомпонентно, но ноль в знаменателе заменяем на 1: в GMod
// деление на ноль даёт ноль, а у нас получился бы `inf`, который
// потом отравляет всю геометрию пропа.
Some(v) => number(
lua,
LuaVector::new(
a.x / safe_divisor(v.x),
a.y / safe_divisor(v.y),
a.z / safe_divisor(v.z),
),
),
None => {
let s = as_scalar(b);
let d = safe_divisor(s);
number(lua, LuaVector::new(a.x / d, a.y / d, a.z / d))
}
}
}
fn safe_divisor(value: f32) -> f32 {
if value.abs() < 1e-6 { 1.0 } else { value }
}
/// Ставит конструкторы `Vector`, `Angle`, `Color` и `math.Clamp` в глобалы.
pub fn install(lua: &Lua) -> Result<(), mlua::Error> {
let globals = lua.globals();
globals.set(
"Vector",
lua.create_function(|lua, args: mlua::MultiValue| {
let values: Vec<Option<f64>> = args.iter().map(|v| value_number(lua, v)).collect();
let get = |i: usize, default: f32| -> f32 {
values
.get(i)
.copied()
.flatten()
.filter(|v| v.is_finite())
.map(|v| v as f32)
.unwrap_or(default)
};
// `Vector()` без аргументов в GMod даёт нулевой вектор, а не
// ошибку: это вызывается из кода, который сам подставляет значения.
lua.create_userdata(LuaVector::new(get(0, 0.0), get(1, 0.0), get(2, 0.0)))
})?,
)?;
globals.set(
"Angle",
lua.create_function(|lua, args: mlua::MultiValue| {
// `Angle(v)` с готовым углом обязан работать: так делает base_ents.
if args.len() == 1 {
if let Some(a) = args.front().and_then(borrow_angle) {
return lua.create_userdata(a);
}
}
let values: Vec<Option<f64>> = args.iter().map(|v| value_number(lua, v)).collect();
let get = |i: usize| -> f32 {
values
.get(i)
.copied()
.flatten()
.filter(|v| v.is_finite())
.map(|v| v as f32)
.unwrap_or(0.0)
};
lua.create_userdata(LuaAngle::new(get(0), get(1), get(2)))
})?,
)?;
globals.set(
"Color",
lua.create_function(|lua, args: mlua::MultiValue| {
if args.len() == 1 {
if let Some(c) = args.front().and_then(borrow_color) {
return lua.create_userdata(c);
}
}
let values: Vec<Option<f64>> = args.iter().map(|v| value_number(lua, v)).collect();
let get = |i: usize, default: u8| {
LuaColor::component(values.get(i).copied().flatten(), default)
};
lua.create_userdata(LuaColor::new(
get(0, 255),
get(1, 255),
get(2, 255),
get(3, 255),
))
})?,
)?;
// `math.Clamp` — есть почти в каждом base_ents-скрипте.
let math_table: Table = globals.get("math")?;
math_table.set(
"Clamp",
lua.create_function(|_, (value, low, high): (f64, f64, f64)| Ok(value.clamp(low, high)))?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use mlua::{Lua, Value};
/// Читает значение-вектор из глобальной переменной.
fn get_vector(lua: &Lua, name: &str) -> LuaVector {
let value = lua.globals().get::<Value>(name).expect("переменная");
borrow_vector(&value).unwrap_or_else(|| panic!("{name} — не вектор"))
}
fn get_angle(lua: &Lua, name: &str) -> LuaAngle {
let value = lua.globals().get::<Value>(name).expect("переменная");
borrow_angle(&value).unwrap_or_else(|| panic!("{name} — не угол"))
}
fn get_color(lua: &Lua, name: &str) -> LuaColor {
let value = lua.globals().get::<Value>(name).expect("переменная");
borrow_color(&value).unwrap_or_else(|| panic!("{name} — не цвет"))
}
/// Готовит рантайм с одним установленным модулем.
fn eval(source: &str) -> Lua {
let lua = Lua::new();
install(&lua).expect("установка типов");
lua.load(source).exec().expect("скрипт не выполнился");
lua
}
#[test]
fn vector_arithmetic_works() {
let lua = eval(
r#"
a = Vector(1, 2, 3)
b = Vector(10, 20, 30)
sum = a + b
diff = b - a
scale = a * 2
part = a * b
"#,
);
let sum = get_vector(&lua, "sum");
assert_eq!(sum, LuaVector::new(11.0, 22.0, 33.0));
let diff = get_vector(&lua, "diff");
assert_eq!(diff, LuaVector::new(9.0, 18.0, 27.0));
let scale = get_vector(&lua, "scale");
assert_eq!(scale, LuaVector::new(2.0, 4.0, 6.0));
// Умножение векторов в GMod покомпонентное, а не скалярное.
let part = get_vector(&lua, "part");
assert_eq!(part, LuaVector::new(10.0, 40.0, 90.0));
}
#[test]
fn vector_length_and_negation() {
let lua = eval(
r#"
v = Vector(3, 4, 0)
len = #v
neg = -v
dist = v:Distance(Vector(0, 0, 0))
dot = v:Dot(Vector(1, 0, 0))
"#,
);
let globals = lua.globals();
let len = <f32 as mlua::FromLua>::from_lua(globals.get("len").unwrap(), &lua).unwrap();
assert!(
(len - 5.0).abs() < 1e-4,
"длина 3-4-0 равна 5, получили {len}"
);
let neg = get_vector(&lua, "neg");
assert_eq!(neg, LuaVector::new(-3.0, -4.0, 0.0));
let dist = <f32 as mlua::FromLua>::from_lua(globals.get("dist").unwrap(), &lua).unwrap();
assert!((dist - 5.0).abs() < 1e-4);
let dot = <f32 as mlua::FromLua>::from_lua(globals.get("dot").unwrap(), &lua).unwrap();
assert!((dot - 3.0).abs() < 1e-4);
}
#[test]
fn vector_fields_are_writable() {
let lua = eval(
r#"
v = Vector(1, 2, 3)
v.x = 10
v.z = -5
"#,
);
let v = get_vector(&lua, "v");
assert_eq!(v, LuaVector::new(10.0, 2.0, -5.0));
}
#[test]
fn missing_arguments_do_not_break_scripts() {
// Обычный код автора вызывает `Vector()` и `Vector(1, 2)`; падать здесь
// нельзя — это гасит бытовые скрипты Sandbox.
let lua = eval(
r#"
zero = Vector()
two = Vector(1, 2)
neg = -two
"#,
);
let _globals = lua.globals();
let zero = get_vector(&lua, "zero");
assert_eq!(zero, LuaVector::default());
let two = get_vector(&lua, "two");
assert_eq!(two, LuaVector::new(1.0, 2.0, 0.0));
let neg = get_vector(&lua, "neg");
assert_eq!(neg, LuaVector::new(-1.0, -2.0, 0.0));
}
#[test]
fn division_by_zero_does_not_produce_infinity() {
// `inf`, попав в позицию пропа, отравляет геометрию всего объекта.
let lua = eval(
r#"
v = Vector(10, 10, 10) / 0
w = Vector(1, 2, 3) / Vector(0, 2, 0)
"#,
);
let _globals = lua.globals();
let v = get_vector(&lua, "v");
assert!(
v.x.is_finite() && v.y.is_finite() && v.z.is_finite(),
"{v:?}"
);
let w = get_vector(&lua, "w");
assert!(w.x.is_finite(), "1/0 должно давать 1, а не inf: {w:?}");
assert!((w.y - 1.0).abs() < 1e-5, "2/2 должно быть 1: {w:?}");
}
#[test]
fn tostring_matches_gmod_format() {
let lua = eval(
r#"
v = tostring(Vector(1, 2, 3))
c = tostring(Color(255, 128, 0, 255))
"#,
);
let globals = lua.globals();
let v = <String as mlua::FromLua>::from_lua(globals.get("v").unwrap(), &lua).unwrap();
assert_eq!(v, "1 2 3");
let c = <String as mlua::FromLua>::from_lua(globals.get("c").unwrap(), &lua).unwrap();
assert_eq!(c, "255 128 0 255");
}
#[test]
fn angle_forward_follows_source_axes() {
// Source — левая система координат: yaw = 90° смотрит вдоль +Y (влево).
let lua = eval(
r#"
zero = Angle(0, 0, 0):Forward()
left = Angle(0, 90, 0):Forward()
"#,
);
let zero = get_vector(&lua, "zero");
assert!(
(zero.x - 1.0).abs() < 1e-4 && zero.y.abs() < 1e-4,
"нулевые углы смотрят вдоль +X, получили {zero:?}"
);
let left = get_vector(&lua, "left");
assert!(
left.y.abs() > 0.99 && left.x.abs() < 1e-4,
"yaw=90° должен смотреть вдоль +Y, получили {left:?}"
);
}
#[test]
fn angle_is_writable_and_comparable() {
let lua = eval(
r#"
a = Angle(0, 0, 0)
b = Angle(0, 0, 0)
a.y = 45
same = (a == b)
c = Angle(a)
"#,
);
let globals = lua.globals();
let a = get_angle(&lua, "a");
assert!((a.y - 45.0).abs() < 1e-5);
let same = <bool as mlua::FromLua>::from_lua(globals.get("same").unwrap(), &lua).unwrap();
assert!(!same, "после записи y углы должны различаться");
// `Angle(a)` обязан копировать, а не терять значение.
let c = get_angle(&lua, "c");
assert!(
(c.y - 45.0).abs() < 1e-5,
"копия должна сохранять углы: {c:?}"
);
}
#[test]
fn color_components_are_clamped_not_rejected() {
let lua = eval(
r#"
hot = Color(1.5 * 300, 0, 0)
thin = Color(0, 0, 0, 128)
"#,
);
let _globals = lua.globals();
let hot = get_color(&lua, "hot");
assert_eq!(hot.r, 255, "компонента вне 0..255 обрезается, а не падает");
let thin = get_color(&lua, "thin");
assert_eq!(thin.a, 128);
}
#[test]
fn nan_from_script_cannot_enter_the_type() {
// `0/0` в Lua даёт `nan`, и он обязан не доехать до физики.
let lua = eval(
r#"
bad = Color(0/0, 0/0, 0/0, 0/0)
v = Vector(0/0, 1, 2)
"#,
);
let _globals = lua.globals();
let bad = get_color(&lua, "bad");
assert_eq!(
bad,
LuaColor::default(),
"nan должен заменяться значением по умолчанию"
);
let v = get_vector(&lua, "v");
assert!(v.x.is_finite(), "nan не должен попадать в вектор");
}
#[test]
fn math_clamp_is_available() {
let lua = eval("v = math.Clamp(15, 0, 10)");
let v = <f64 as mlua::FromLua>::from_lua(lua.globals().get("v").unwrap(), &lua).unwrap();
assert_eq!(v, 10.0);
}
/// Строковые представления должны читаться как в GMod — на этом завязан
/// `ErrorNoHud` и текст ошибок в консоли.
#[test]
fn tostring_works_through_global_tostring() {
let lua = eval("s = tostring(Vector(1, 2, 3))");
let s = <String as mlua::FromLua>::from_lua(lua.globals().get("s").unwrap(), &lua)
.expect("строка");
assert_eq!(s, "1 2 3");
}
}

View file

@ -3,6 +3,8 @@
//! v0: `print`/`Msg`, `hook.*`, `timer.Simple`, `concommand.*` (реестр),
//! вывод в буфер для HUD/консоли. Игровые библиотеки — следующие итерации.
pub mod gmod;
use mlua::{Function, Lua, MultiValue, RegistryKey, Table, Value};
use std::cell::RefCell;
use std::rc::Rc;
@ -33,6 +35,7 @@ impl LuaRuntime {
let output: Output = Rc::new(RefCell::new(Vec::new()));
install_api(&lua, &timers, &output)?;
gmod::install(&lua)?;
Ok(Self {
lua,
@ -636,4 +639,37 @@ local t = [[raw ! text]]
assert!(rt.run_command("sr_test", "").expect("вызов"));
assert!(rt.take_output().iter().any(|l| l == "ran"));
}
/// Типы GMod должны работать **вместе** с остальным API: скрипт,
/// который печатает вектор через `print`, обязан печатать и через
/// `Msg`, иначе типы работают только в вакууме.
#[test]
fn gmod_types_work_inside_the_real_runtime() {
let runtime = super::LuaRuntime::new().expect("рантайм");
runtime
.run(
"types.lua",
br#"
a = Vector(1, 2, 3)
b = Vector(10, 20, 30)
print(tostring(a + b))
print(tostring(#(a + b)))
print(tostring(Angle(0, 90, 0):Forward()))
print(tostring(Color(255, 128, 0, 255)))
local v = Vector(0, 0, 0)
v.x = 5
print(tostring(v))
print(tostring(math.Clamp(42, 0, 10)))
"#,
)
.expect("скрипт выполнился");
let out = runtime.take_output();
assert_eq!(out[0], "11 22 33", "сложение векторов");
// sqrt(11² + 22² + 33²) = sqrt(1694) = 41.1582…
assert_eq!(out[1], "41.158229827881", "длина (11,22,33)");
assert_eq!(out[2], "0 1 0", "yaw=90° смотрит вдоль +Y");
assert_eq!(out[3], "255 128 0 255", "цвет печатается как в GMod");
assert_eq!(out[4], "5 0 0", "поле записывается");
assert_eq!(out[5], "10", "math.Clamp");
}
}

View file

@ -451,3 +451,63 @@ Rapier обслуживает только **динамические тела**
- Брашевые `func_door` / `func_button` как ограничивающие тела.
- `env_explosion` через `apply_explosion` (метод готов, вызов из Lua — нет).
- Замер FPS с физикой и без, чтобы понять цену 2 121 коллайдера.
## Часть D1. Lua-типы GMod (сделано)
### D1. Что сделано
`crates/lua/src/gmod.rs` — `Vector`, `Angle`, `Color` как userdata с
арифметикой, полями на запись и `tostring` в формате GMod. Установлены в
глобалы вместе с `math.Clamp`.
Решения, важные для совместимости:
- **Пропущенных аргументов не бывает.** `Vector()` и `Vector(1, 2)` обязаны
работать: так вызывается бытовой код Sandbox. Отсутствующее значение
заменяется нулём, а не ошибкой.
- **Деление на ноль даёт 1, а не `inf`.** `inf` в позиции пропа отравляет
геометрию всего объекта и потом всплывает в отрисовке.
- **NaN из скрипта не доезжает до физики.** `Color(0/0, ...)` даёт белый,
`Vector(0/0, 1, 2)` — вектор без NaN.
- **Компоненты цвета обрезаются в 0..255**, а не отвергаются:
`Color(1.5 * 300, 0, 0)` — обычное дело в скриптах.
- **Source — левая система координат**: `Angle(0,0,0):Forward()` смотрит вдоль
`+X`, `Angle(0,90,0):Forward()` — вдоль `+Y`, знак Z у forward
отрицательный.
### D1.1. Дефект чтения чисел в mlua 0.11
`Value::as_number()` в mlua 0.11 различает **только** `Value::Number` и молча
возвращает `None` на `Value::Integer`. LuaJIT отдаёт целые именно как
`Integer`, поэтому наивное чтение теряло **все** целочисленные аргументы:
Vector(1, 2, 3) --> Vector(0, 0, 0)
Выглядело это как «арифметика не работает», хотя дело было в чтении чисел.
Собственный `value_number()` принимает `Number`, `Integer` и строку (числом).
Тест на это обязателен: без него `Vector()` с целыми просто тихо отдаёт нули.
### D1.2. Шум тригонометрии
`cos(90°)` в f32 — это 6.1e-17, а не ноль. Без обнуления
`Angle(0, 90, 0):Forward()` печатает `-0.00000004371139 1 -0`, и такие
значения копятся в преобразованиях координат. Порог — 1e-6.
### D1.3. Особенности API mlua 0.11, на которые ушло время
- Для userdata **нет** автоматического `FromLua`: чтение только через
`AnyUserData::borrow::<T>()`. `Lua::from_value` отсутствует, есть
`FromLua::from_lua(value, lua)`.
- Поля — `add_fields` (трейт `UserDataFields`), методы и метаметоды —
`add_methods` (трейт `UserDataMethods`). Один трейт на всё не работает.
- `add_meta_method` получает `(&Lua, &T, A)`: значение объекта идёт вторым
аргументом, лишние аргументы Lua (`__unm(a, a)`) отбрасываются через `()`.
- `add_function` получает объект как `AnyUserData` первым элементом кортежа.
### D1.4. Что осталось по части D1
- `Entity()` и объект сущности: `GetPos`/`SetPos`/`GetClass`/`IsValid`.
- `ents.*`: `Create`, `GetAll`, `FindInSphere`, `GetPlayer`.
- Связка с движком: `glua` не должен зависеть от `engine`, обмен — через
колбэки, которые регистрирует клиент.
- `Vector:Angle()`, `Vector:Distance` между сущностями.