Lua: база SWEP и GMod-заглушки для оружия
- swep_base.lua: GetOwner/GetViewModel, NetworkVar, SetNextPrimaryFire, Clip1, SetHoldType, EmitSound и безопасные заглушки; - глобальные CurTime, IsValid, player.GetAll (таблица player), util.TraceLine/TraceHull, trace.*, sound.*, константы CLIENT/SERVER; - game.SinglePlayer стал функцией (как в GMod), GetConVar знает sv_defaultdeployspeed/phys_pushscale — ноль давал inf в Deploy; - загрузчик SWEP даёт базовые методы через метатаблицу, инициализирует SetupDataTables/Initialize и стартовый магазин; - модель без геометрии (c_arms) теперь логирует причину, а не молчит; - тест прогоняет реальные Deploy/PrimaryAttack/Think/Holster трёх SWEP.
This commit is contained in:
parent
300a6c6d98
commit
a46e4d827d
7 changed files with 792 additions and 34 deletions
|
|
@ -2882,7 +2882,15 @@ impl World {
|
||||||
budget: &mut usize,
|
budget: &mut usize,
|
||||||
) -> Option<(usize, u32, u32)> {
|
) -> Option<(usize, u32, u32)> {
|
||||||
let key = crate::model::normalize_model_name(model_name);
|
let key = crate::model::normalize_model_name(model_name);
|
||||||
let model = crate::model::load_model(vfs, &key, budget).ok()?;
|
let model = match crate::model::load_model(vfs, &key, budget) {
|
||||||
|
Ok(model) => model,
|
||||||
|
Err(err) => {
|
||||||
|
// Молчаливый `None` прятал причину («вьюмодель не
|
||||||
|
// загрузилась») — в логе должна быть конкретная ошибка.
|
||||||
|
tracing::warn!(model = %key, "модель не загружена: {err}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut mesh_data = PropMesh::from_vertices(&model.vertices);
|
let mut mesh_data = PropMesh::from_vertices(&model.vertices);
|
||||||
mesh_data.bones = model.bones.clone();
|
mesh_data.bones = model.bones.clone();
|
||||||
mesh_data.skins = model.skins.clone();
|
mesh_data.skins = model.skins.clone();
|
||||||
|
|
|
||||||
|
|
@ -306,17 +306,81 @@ pub fn install(lua: &Lua, host: HostRef) -> Result<(), mlua::Error> {
|
||||||
|
|
||||||
globals.set("ents", &ents)?;
|
globals.set("ents", &ents)?;
|
||||||
|
|
||||||
let host_player = host.clone();
|
// `player` в GMod — таблица: `player.GetAll()`, `player.GetByID()`, и при
|
||||||
globals.set(
|
// этом её можно вызвать (`player()`, `player(1)`). Функция вместо таблицы
|
||||||
"player",
|
// роняла скрипты на первом же `player.GetAll()`.
|
||||||
|
let player_table = lua.create_table()?;
|
||||||
|
let host_all = host.clone();
|
||||||
|
player_table.set(
|
||||||
|
"GetAll",
|
||||||
lua.create_function(move |lua, ()| {
|
lua.create_function(move |lua, ()| {
|
||||||
let entity = host_player.try_borrow().ok().and_then(|h| h.player());
|
let players: Vec<EntityRef> = host_all
|
||||||
|
.try_borrow()
|
||||||
|
.ok()
|
||||||
|
.and_then(|h| h.player())
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
Ok(Value::Table(push_list(lua, &players, &host_all)?))
|
||||||
|
})?,
|
||||||
|
)?;
|
||||||
|
let host_humans = host.clone();
|
||||||
|
player_table.set(
|
||||||
|
"GetHumans",
|
||||||
|
lua.create_function(move |lua, ()| {
|
||||||
|
let players: Vec<EntityRef> = host_humans
|
||||||
|
.try_borrow()
|
||||||
|
.ok()
|
||||||
|
.and_then(|h| h.player())
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
Ok(Value::Table(push_list(lua, &players, &host_humans)?))
|
||||||
|
})?,
|
||||||
|
)?;
|
||||||
|
let host_bots = host.clone();
|
||||||
|
player_table.set(
|
||||||
|
"GetBots",
|
||||||
|
lua.create_function(move |lua, ()| {
|
||||||
|
let empty: Vec<EntityRef> = Vec::new();
|
||||||
|
Ok(Value::Table(push_list(lua, &empty, &host_bots)?))
|
||||||
|
})?,
|
||||||
|
)?;
|
||||||
|
let host_count = host.clone();
|
||||||
|
player_table.set(
|
||||||
|
"GetCount",
|
||||||
|
lua.create_function(move |_, ()| {
|
||||||
|
Ok(host_count
|
||||||
|
.try_borrow()
|
||||||
|
.ok()
|
||||||
|
.and_then(|h| h.player())
|
||||||
|
.map(|_| 1)
|
||||||
|
.unwrap_or(0))
|
||||||
|
})?,
|
||||||
|
)?;
|
||||||
|
let host_by_id = host.clone();
|
||||||
|
player_table.set(
|
||||||
|
"GetByID",
|
||||||
|
lua.create_function(move |lua, _id: i64| {
|
||||||
|
let entity = host_by_id.try_borrow().ok().and_then(|h| h.player());
|
||||||
match entity {
|
match entity {
|
||||||
Some(entity) => Ok(Value::UserData(wrap(lua, entity, &host_player)?)),
|
Some(entity) => Ok(Value::UserData(wrap(lua, entity, &host_by_id)?)),
|
||||||
None => Ok(Value::Nil),
|
None => Ok(Value::Nil),
|
||||||
}
|
}
|
||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
|
let host_call = host.clone();
|
||||||
|
let meta = lua.create_table()?;
|
||||||
|
meta.set(
|
||||||
|
"__call",
|
||||||
|
lua.create_function(move |lua, (_table, _id): (Table, Option<i64>)| {
|
||||||
|
let entity = host_call.try_borrow().ok().and_then(|h| h.player());
|
||||||
|
match entity {
|
||||||
|
Some(entity) => Ok(Value::UserData(wrap(lua, entity, &host_call)?)),
|
||||||
|
None => Ok(Value::Nil),
|
||||||
|
}
|
||||||
|
})?,
|
||||||
|
)?;
|
||||||
|
player_table.set_metatable(Some(meta))?;
|
||||||
|
globals.set("player", player_table)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,11 @@
|
||||||
pub mod ents;
|
pub mod ents;
|
||||||
pub mod gmod;
|
pub mod gmod;
|
||||||
pub mod stdlib;
|
pub mod stdlib;
|
||||||
|
pub mod swep;
|
||||||
|
|
||||||
use mlua::{Function, Lua, MultiValue, RegistryKey, Table, Value};
|
use mlua::{Function, Lua, MultiValue, RegistryKey, Table, Value};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
|
@ -82,7 +84,46 @@ impl LuaRuntime {
|
||||||
/// Нужен клиенту для `PrimaryAttack`/`SecondaryAttack`/`Deploy`: скрипты
|
/// Нужен клиенту для `PrimaryAttack`/`SecondaryAttack`/`Deploy`: скрипты
|
||||||
/// определяют их как методы (`function SWEP:PrimaryAttack()`), поэтому
|
/// определяют их как методы (`function SWEP:PrimaryAttack()`), поэтому
|
||||||
/// первым аргументом идёт сама таблица.
|
/// первым аргументом идёт сама таблица.
|
||||||
|
///
|
||||||
|
/// Паника внутри mlua/FFI не должна ронять клиент (правило #2 AGENTS.md):
|
||||||
|
/// она превращается в обычную ошибку Lua и логируется вызывающим.
|
||||||
pub fn call_weapon_method(&self, class: &str, method: &str) -> Result<(), LuaError> {
|
pub fn call_weapon_method(&self, class: &str, method: &str) -> Result<(), LuaError> {
|
||||||
|
match catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
self.call_weapon_method_inner(class, method)
|
||||||
|
})) {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => Err(LuaError::Lua(mlua::Error::runtime(format!(
|
||||||
|
"паника при вызове {class}:{method}"
|
||||||
|
)))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_weapon_method_inner(&self, class: &str, method: &str) -> Result<(), LuaError> {
|
||||||
|
let swep = self.weapon_table(class)?;
|
||||||
|
let Value::Function(function) = swep.get::<Value>(method)? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
function.call::<()>(swep)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вызывает метод SWEP и возвращает число (например, `Clip1` или
|
||||||
|
/// `GetNextPrimaryFire`). Не-число/ошибка — `None`.
|
||||||
|
pub fn call_weapon_number(&self, class: &str, method: &str) -> Option<f64> {
|
||||||
|
catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
let swep = self.weapon_table(class).ok()?;
|
||||||
|
let Value::Function(function) = swep.get::<Value>(method).ok()? else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let value = function.call::<Value>(swep).ok()?;
|
||||||
|
value_as_f64(&value)
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Таблица SWEP из реестра.
|
||||||
|
fn weapon_table(&self, class: &str) -> Result<Table, LuaError> {
|
||||||
let globals = self.lua.globals();
|
let globals = self.lua.globals();
|
||||||
let weapons: Table = globals.get("weapons")?;
|
let weapons: Table = globals.get("weapons")?;
|
||||||
let get: Function = weapons.get("Get")?;
|
let get: Function = weapons.get("Get")?;
|
||||||
|
|
@ -91,28 +132,64 @@ impl LuaRuntime {
|
||||||
"оружие {class} не зарегистрировано"
|
"оружие {class} не зарегистрировано"
|
||||||
))));
|
))));
|
||||||
};
|
};
|
||||||
let Value::Function(function) = swep.get::<Value>(method)? else {
|
Ok(swep)
|
||||||
return Ok(());
|
}
|
||||||
};
|
|
||||||
function.call::<()>(swep)?;
|
/// Значение поля SWEP по пути через точку (`ViewModel`, `Primary.ClipSize`).
|
||||||
Ok(())
|
pub fn weapon_field(&self, class: &str, field: &str) -> Option<Value> {
|
||||||
|
catch_unwind(AssertUnwindSafe(|| self.weapon_field_inner(class, field)))
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn weapon_field_inner(&self, class: &str, field: &str) -> Option<Value> {
|
||||||
|
let mut current = Value::Table(self.weapon_table(class).ok()?);
|
||||||
|
for part in field.split('.') {
|
||||||
|
let Value::Table(table) = current else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
current = table.get::<Value>(part).ok()?;
|
||||||
|
}
|
||||||
|
Some(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Значение строкового поля SWEP (`ViewModel`, `PrintName`, ...).
|
/// Значение строкового поля SWEP (`ViewModel`, `PrintName`, ...).
|
||||||
|
///
|
||||||
|
/// `Model("...")` в GMod-скриптах — таблица с полем `path`; такие поля
|
||||||
|
/// тоже читаются как строка. `field` может быть путём (`Primary.Ammo`).
|
||||||
pub fn weapon_string(&self, class: &str, field: &str) -> Option<String> {
|
pub fn weapon_string(&self, class: &str, field: &str) -> Option<String> {
|
||||||
let globals = self.lua.globals();
|
match self.weapon_field(class, field)? {
|
||||||
let weapons: Table = globals.get("weapons").ok()?;
|
Value::String(value) => Some(value.to_string_lossy().to_string()),
|
||||||
let get: Function = weapons.get("Get").ok()?;
|
Value::Table(value) => value.get::<Option<String>>("path").ok().flatten(),
|
||||||
let Value::Table(swep) = get.call::<Value>(class.to_string()).ok()? else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
match swep.get::<Value>(field) {
|
|
||||||
Ok(Value::String(value)) => Some(value.to_string_lossy().to_string()),
|
|
||||||
Ok(Value::Table(value)) => value.get::<Option<String>>("path").ok().flatten(),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Числовое поле SWEP (int/float/числовая строка).
|
||||||
|
pub fn weapon_number(&self, class: &str, field: &str) -> Option<f64> {
|
||||||
|
value_as_f64(&self.weapon_field(class, field)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Булево поле SWEP по правилам Lua: всё, кроме `nil`/`false`, истинно.
|
||||||
|
pub fn weapon_bool(&self, class: &str, field: &str) -> Option<bool> {
|
||||||
|
let value = self.weapon_field(class, field)?;
|
||||||
|
Some(!matches!(value, Value::Nil | Value::Boolean(false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Запоминает активное оружие для `owner:GetActiveWeapon()` в скриптах.
|
||||||
|
pub fn set_active_weapon(&self, class: Option<&str>) {
|
||||||
|
let value = match class.and_then(|class| self.weapon_table(class).ok()) {
|
||||||
|
Some(swep) => Value::Table(swep),
|
||||||
|
None => Value::Nil,
|
||||||
|
};
|
||||||
|
let _ = self.lua.globals().set("__sr_active_weapon", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Время в секундах от запуска клиента — то же, что `CurTime()` в Lua.
|
||||||
|
pub fn cur_time(&self) -> f64 {
|
||||||
|
stdlib::cur_time()
|
||||||
|
}
|
||||||
|
|
||||||
/// Загружает скрипты GMod: `lua/weapons/*.lua` и `lua/entities/*.lua`.
|
/// Загружает скрипты GMod: `lua/weapons/*.lua` и `lua/entities/*.lua`.
|
||||||
///
|
///
|
||||||
/// Каждый скрипт выполняется со свежей таблицей `SWEP`/`ENT` (у оружия
|
/// Каждый скрипт выполняется со свежей таблицей `SWEP`/`ENT` (у оружия
|
||||||
|
|
@ -156,6 +233,12 @@ impl LuaRuntime {
|
||||||
if kind == "weapon" {
|
if kind == "weapon" {
|
||||||
table.set("Primary", self.lua.create_table()?)?;
|
table.set("Primary", self.lua.create_table()?)?;
|
||||||
table.set("Secondary", self.lua.create_table()?)?;
|
table.set("Secondary", self.lua.create_table()?)?;
|
||||||
|
// Базовые методы (`GetOwner`, `SetNextPrimaryFire`,
|
||||||
|
// `NetworkVar`, ...) приходят через метатаблицу: скрипт
|
||||||
|
// видит их как методы SWEP, но не перезаписывает файл.
|
||||||
|
if let Ok(Value::Table(meta)) = globals.get::<Value>("__sr_swep_meta") {
|
||||||
|
table.set_metatable(Some(meta))?;
|
||||||
|
}
|
||||||
globals.set("SWEP", &table)?;
|
globals.set("SWEP", &table)?;
|
||||||
} else {
|
} else {
|
||||||
globals.set("ENT", &table)?;
|
globals.set("ENT", &table)?;
|
||||||
|
|
@ -190,11 +273,26 @@ impl LuaRuntime {
|
||||||
entries
|
entries
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
entries.set(class, &table)?;
|
entries.set(class.clone(), &table)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})();
|
})();
|
||||||
if registered.is_ok() {
|
if registered.is_ok() {
|
||||||
weapons += 1;
|
weapons += 1;
|
||||||
|
// Стартовый запас: в GMod `DefaultClip` попадает в
|
||||||
|
// оружие при выдаче. Без этого `Clip1()` у медика был
|
||||||
|
// бы нулевым, и HUD показывал бы пустой магазин.
|
||||||
|
if let Some(clip) = self.weapon_number(&class, "Primary.ClipSize")
|
||||||
|
&& clip > 0.0
|
||||||
|
{
|
||||||
|
let _ = table.set("__clip", clip as i64);
|
||||||
|
}
|
||||||
|
// Порядок как в GMod: сначала сетевые переменные,
|
||||||
|
// потом `Initialize` (он на них опирается).
|
||||||
|
for method in ["SetupDataTables", "Initialize"] {
|
||||||
|
if let Err(err) = self.call_weapon_method(&class, method) {
|
||||||
|
eprintln!("оружие {class}: {method}: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -633,13 +731,40 @@ fn install_api(lua: &Lua, timers: &TimerQueue, output: &Output) -> Result<(), ml
|
||||||
globals.set(
|
globals.set(
|
||||||
"GetConVar",
|
"GetConVar",
|
||||||
lua.create_function(|lua, name: String| {
|
lua.create_function(|lua, name: String| {
|
||||||
let _ = name;
|
// Значения реальных движковых конваров, на которые опираются
|
||||||
|
// скрипты оружия. Ноль у `sv_defaultdeployspeed` превращал
|
||||||
|
// `SequenceDuration()/speed` в `inf` — оружие больше не стреляло.
|
||||||
|
let float: f64 = match name.as_str() {
|
||||||
|
"sv_defaultdeployspeed" | "phys_pushscale" => 1.0,
|
||||||
|
"sv_gravity" => 600.0,
|
||||||
|
"sv_maxspeed" => 320.0,
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
let t = lua.create_table()?;
|
let t = lua.create_table()?;
|
||||||
t.set("GetBool", lua.create_function(|_, ()| Ok(false))?)?;
|
t.set(
|
||||||
t.set("GetFloat", lua.create_function(|_, ()| Ok(0.0))?)?;
|
"GetBool",
|
||||||
t.set("GetInt", lua.create_function(|_, ()| Ok(0))?)?;
|
lua.create_function(move |_, ()| Ok(float != 0.0))?,
|
||||||
t.set("GetString", lua.create_function(|_, ()| Ok(String::new()))?)?;
|
)?;
|
||||||
|
t.set("GetFloat", lua.create_function(move |_, ()| Ok(float))?)?;
|
||||||
|
t.set(
|
||||||
|
"GetInt",
|
||||||
|
lua.create_function(move |_, ()| Ok(float as i64))?,
|
||||||
|
)?;
|
||||||
|
t.set(
|
||||||
|
"GetString",
|
||||||
|
lua.create_function(move |_, ()| {
|
||||||
|
Ok(if float.fract() == 0.0 {
|
||||||
|
format!("{}", float as i64)
|
||||||
|
} else {
|
||||||
|
float.to_string()
|
||||||
|
})
|
||||||
|
})?,
|
||||||
|
)?;
|
||||||
t.set("SetValue", lua.create_function(|_, _: Value| Ok(()))?)?;
|
t.set("SetValue", lua.create_function(|_, _: Value| Ok(()))?)?;
|
||||||
|
t.set(
|
||||||
|
"GetName",
|
||||||
|
lua.create_function(move |_, ()| Ok(name.clone()))?,
|
||||||
|
)?;
|
||||||
Ok(t)
|
Ok(t)
|
||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -775,6 +900,17 @@ fn long_bracket(bytes: &[u8], at: usize) -> (usize, ()) {
|
||||||
(bytes.len() - at, ())
|
(bytes.len() - at, ())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Число из значения Lua: целые LuaJIT приходят как `Integer`, дробные —
|
||||||
|
/// как `Number`; строки тоже принимаются (конфиги скриптов).
|
||||||
|
fn value_as_f64(value: &Value) -> Option<f64> {
|
||||||
|
match value {
|
||||||
|
Value::Integer(i) => Some(*i as f64),
|
||||||
|
Value::Number(n) if n.is_finite() => Some(*n),
|
||||||
|
Value::String(s) => s.to_str().ok()?.trim().parse().ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Преобразует Lua-значение в строку (как `print` в GMod).
|
/// Преобразует Lua-значение в строку (как `print` в GMod).
|
||||||
fn lua_value_to_string(value: &Value) -> String {
|
fn lua_value_to_string(value: &Value) -> String {
|
||||||
match value {
|
match value {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ pub fn install(lua: &Lua, map: &str) -> Result<(), mlua::Error> {
|
||||||
install_weapons(lua)?;
|
install_weapons(lua)?;
|
||||||
install_scripted_ents(lua)?;
|
install_scripted_ents(lua)?;
|
||||||
install_stubs(lua)?;
|
install_stubs(lua)?;
|
||||||
|
crate::swep::install(lua)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -432,10 +433,10 @@ fn install_game(lua: &Lua, map: String) -> Result<(), mlua::Error> {
|
||||||
let globals = lua.globals();
|
let globals = lua.globals();
|
||||||
let game: Table = globals.get("game")?;
|
let game: Table = globals.get("game")?;
|
||||||
|
|
||||||
// Одиночная игра. Скрипты Sandbox ветвятся по нему постоянно, и в
|
// Одиночная игра. В GMod `game.SinglePlayer` — **функция**, и скрипты
|
||||||
// одиночном режиме оно обязано быть `true` — иначе отключится большая
|
// оружия вызывают её как `game.SinglePlayer()`; булево значение падало
|
||||||
// часть меню.
|
// на первом же таком вызове.
|
||||||
game.set("SinglePlayer", Value::Boolean(true))?;
|
game.set("SinglePlayer", lua.create_function(|_, ()| Ok(true))?)?;
|
||||||
game.set("IsDedicated", Value::Boolean(false))?;
|
game.set("IsDedicated", Value::Boolean(false))?;
|
||||||
game.set("folder", "Garry's Mod")?;
|
game.set("folder", "Garry's Mod")?;
|
||||||
game.set("title", "Garry's Mod")?;
|
game.set("title", "Garry's Mod")?;
|
||||||
|
|
@ -448,9 +449,11 @@ fn install_game(lua: &Lua, map: String) -> Result<(), mlua::Error> {
|
||||||
"GetTime",
|
"GetTime",
|
||||||
lua.create_function(|_, ()| {
|
lua.create_function(|_, ()| {
|
||||||
// Время от запуска клиента: таймеры GMod считают от него же.
|
// Время от запуска клиента: таймеры GMod считают от него же.
|
||||||
Ok(CLIENT_START.elapsed().as_secs_f64())
|
Ok(cur_time())
|
||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
|
lua.globals()
|
||||||
|
.set("CurTime", lua.create_function(|_, ()| Ok(cur_time()))?)?;
|
||||||
|
|
||||||
game.set(
|
game.set(
|
||||||
"CleanUpMap",
|
"CleanUpMap",
|
||||||
|
|
@ -464,10 +467,15 @@ fn install_game(lua: &Lua, map: String) -> Result<(), mlua::Error> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Время запуска клиента — общая точка отсчёта для таймеров.
|
/// Время запуска клиента — общая точка отсчёта для таймеров и `CurTime`.
|
||||||
static CLIENT_START: std::sync::LazyLock<std::time::Instant> =
|
static CLIENT_START: std::sync::LazyLock<std::time::Instant> =
|
||||||
std::sync::LazyLock::new(std::time::Instant::now);
|
std::sync::LazyLock::new(std::time::Instant::now);
|
||||||
|
|
||||||
|
/// Секунды от запуска клиента (`CurTime` и `game.GetTime`).
|
||||||
|
pub fn cur_time() -> f64 {
|
||||||
|
CLIENT_START.elapsed().as_secs_f64()
|
||||||
|
}
|
||||||
|
|
||||||
/// Расширения `string` и `table` из GMod.
|
/// Расширения `string` и `table` из GMod.
|
||||||
fn install_string_table(lua: &Lua) -> Result<(), mlua::Error> {
|
fn install_string_table(lua: &Lua) -> Result<(), mlua::Error> {
|
||||||
let globals = lua.globals();
|
let globals = lua.globals();
|
||||||
|
|
@ -850,16 +858,18 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn single_player_is_true() {
|
fn single_player_is_true() {
|
||||||
// Sandbox ветвится по game.SinglePlayer постоянно.
|
// Sandbox ветвится по game.SinglePlayer постоянно; в GMod это
|
||||||
|
// функция, а не поле.
|
||||||
let lua = ready("gm_test");
|
let lua = ready("gm_test");
|
||||||
assert!(
|
assert!(
|
||||||
eval(&lua, "return game.SinglePlayer")
|
eval(&lua, "return game.SinglePlayer()")
|
||||||
.front()
|
.front()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_boolean()
|
.as_boolean()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
);
|
);
|
||||||
assert_eq!(string(&lua, "return game.GetMap()"), "gm_test");
|
assert_eq!(string(&lua, "return game.GetMap()"), "gm_test");
|
||||||
|
assert!(number(&lua, "return CurTime()") >= 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
312
crates/lua/src/swep.rs
Normal file
312
crates/lua/src/swep.rs
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
//! GMod-API для SWEP-скриптов: база оружия, владелец, вьюмодель, трассы.
|
||||||
|
//!
|
||||||
|
//! Реальные `lua/weapons/*.lua` из Garry's Mod написаны под движковый API:
|
||||||
|
//! `self:SetHoldType`, `self:SetNextPrimaryFire`, `CurTime`, `util.TraceLine`,
|
||||||
|
//! `owner:GetViewModel()` и т. д. Здесь — безопасный минимум, без которого
|
||||||
|
//! `PrimaryAttack` падает на первом же вызове.
|
||||||
|
//!
|
||||||
|
//! Звук и эффекты **не эмулируются**: `EmitSound`/`sound.Play` принимают
|
||||||
|
//! аргументы и ничего не делают. Трассы возвращают пустой результат
|
||||||
|
//! (`Hit = false`), поэтому урон и физика не появляются из воздуха — но и
|
||||||
|
//! клиент не падает от отсутствующего API.
|
||||||
|
|
||||||
|
use mlua::{AnyUserData, Function, Lua, ObjectLike, Table, Value};
|
||||||
|
|
||||||
|
use crate::gmod::LuaVector;
|
||||||
|
|
||||||
|
/// Ставит глобальные константы, заглушки и базовые методы SWEP.
|
||||||
|
pub fn install(lua: &Lua) -> Result<(), mlua::Error> {
|
||||||
|
let globals = lua.globals();
|
||||||
|
|
||||||
|
// Сторона исполнения: скрипты оружия ветвятся по `CLIENT`/`SERVER`.
|
||||||
|
globals.set("CLIENT", true)?;
|
||||||
|
globals.set("SERVER", false)?;
|
||||||
|
// `NULL` в GMod — специальный userdata; `nil` ведёт себя так же в
|
||||||
|
// проверках `if ( !IsValid( ... ) )`.
|
||||||
|
globals.set("NULL", Value::Nil)?;
|
||||||
|
|
||||||
|
for (name, value) in [
|
||||||
|
("PLAYER_ATTACK1", 1),
|
||||||
|
("PLAYER_ATTACK2", 2),
|
||||||
|
("PLAYER_ATTACK_CRIT", 4),
|
||||||
|
("PLAYER_ATTACK_SWING", 8),
|
||||||
|
("PLAYER_ATTACK_NODRAW", 16),
|
||||||
|
("ACT_VM_PRIMARYATTACK", 1),
|
||||||
|
("ACT_VM_SECONDARYATTACK", 2),
|
||||||
|
("ACT_VM_IDLE", 3),
|
||||||
|
("ACT_VM_DRAW", 4),
|
||||||
|
("ACT_VM_HOLSTER", 5),
|
||||||
|
("ACT_VM_RELOAD", 6),
|
||||||
|
// Маски трасс: значения как в Source, но `util.TraceLine` пока
|
||||||
|
// заглушка — важна не точность, а то, что константа не nil.
|
||||||
|
("MASK_SHOT_HULL", 0x0200_400B),
|
||||||
|
("MASK_SHOT", 0x1000_400B),
|
||||||
|
("MASK_SOLID", 0x0200_400B),
|
||||||
|
] {
|
||||||
|
globals.set(name, value)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
globals.set(
|
||||||
|
"IsValid",
|
||||||
|
lua.create_function(|_, value: Value| Ok(is_valid(&value)))?,
|
||||||
|
)?;
|
||||||
|
globals.set(
|
||||||
|
"EmitSound",
|
||||||
|
lua.create_function(|_, _: mlua::MultiValue| Ok(()))?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
install_trace(lua)?;
|
||||||
|
install_sound(lua)?;
|
||||||
|
install_empty_player(lua)?;
|
||||||
|
install_base(lua)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `player` до подключения мира: пустая таблица, но с рабочими методами.
|
||||||
|
///
|
||||||
|
/// `attach_host` заменит её таблицей с настоящими сущностями. Без заглушки
|
||||||
|
/// скрипт, вызванный до подключения хоста (или в юнит-тесте), падал на
|
||||||
|
/// `player.GetAll()` с «attempt to index global 'player'».
|
||||||
|
fn install_empty_player(lua: &Lua) -> Result<(), mlua::Error> {
|
||||||
|
let globals = lua.globals();
|
||||||
|
if !matches!(globals.get::<Value>("player")?, Value::Nil) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let player = lua.create_table()?;
|
||||||
|
let empty_list = lua.create_function(|lua, ()| lua.create_table())?;
|
||||||
|
player.set("GetAll", empty_list.clone())?;
|
||||||
|
player.set("GetHumans", empty_list.clone())?;
|
||||||
|
player.set("GetBots", empty_list)?;
|
||||||
|
player.set("GetCount", lua.create_function(|_, ()| Ok(0))?)?;
|
||||||
|
player.set(
|
||||||
|
"GetByID",
|
||||||
|
lua.create_function(|_, _id: i64| Ok(Value::Nil))?,
|
||||||
|
)?;
|
||||||
|
let meta = lua.create_table()?;
|
||||||
|
meta.set(
|
||||||
|
"__call",
|
||||||
|
lua.create_function(|_, (_table, _id): (Table, Option<i64>)| Ok(Value::Nil))?,
|
||||||
|
)?;
|
||||||
|
player.set_metatable(Some(meta))?;
|
||||||
|
globals.set("player", player)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверка значения как `IsValid` в GMod.
|
||||||
|
///
|
||||||
|
/// `nil`/`false` невалидны; userdata спрашивает свой `IsValid()` (так
|
||||||
|
/// отсеиваются удалённые энтити), таблицы и функции валидны.
|
||||||
|
fn is_valid(value: &Value) -> bool {
|
||||||
|
match value {
|
||||||
|
Value::Nil | Value::Boolean(false) => false,
|
||||||
|
Value::UserData(ud) => userdata_valid(ud),
|
||||||
|
// Заглушка NULL-сущности — таблица со своим `IsValid() == false`.
|
||||||
|
Value::Table(table) => match table.get::<Option<Function>>("IsValid") {
|
||||||
|
Ok(Some(function)) => function.call::<bool>(table.clone()).unwrap_or(true),
|
||||||
|
_ => true,
|
||||||
|
},
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn userdata_valid(ud: &AnyUserData) -> bool {
|
||||||
|
match ud.get::<Option<Function>>("IsValid") {
|
||||||
|
Ok(Some(function)) => function.call::<bool>(ud.clone()).unwrap_or(true),
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `util.TraceLine`/`TraceHull` и таблица `trace`: пустой результат.
|
||||||
|
fn install_trace(lua: &Lua) -> Result<(), mlua::Error> {
|
||||||
|
let globals = lua.globals();
|
||||||
|
let util: Table = globals.get("util")?;
|
||||||
|
let line = lua.create_function(|lua, input: Option<Table>| empty_trace(lua, input.as_ref()))?;
|
||||||
|
util.set("TraceLine", line.clone())?;
|
||||||
|
util.set("TraceHull", line.clone())?;
|
||||||
|
util.set("TraceEntity", line.clone())?;
|
||||||
|
util.set("TracePhysics", line.clone())?;
|
||||||
|
|
||||||
|
let trace = lua.create_table()?;
|
||||||
|
trace.set("Line", line.clone())?;
|
||||||
|
trace.set("Hull", line)?;
|
||||||
|
globals.set("trace", trace)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Пустая трасса: ни во что не попали.
|
||||||
|
///
|
||||||
|
/// Поля повторяют структуру GMod достаточно, чтобы скрипт читал
|
||||||
|
/// `tr.Hit`, `tr.Entity`, `tr.HitPos`, `tr.Normal`, `tr.Fraction`.
|
||||||
|
fn empty_trace(lua: &Lua, input: Option<&Table>) -> mlua::Result<Table> {
|
||||||
|
let start = input
|
||||||
|
.and_then(|table| table.get::<Option<LuaVector>>("start").ok().flatten())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let endpos = input
|
||||||
|
.and_then(|table| table.get::<Option<LuaVector>>("endpos").ok().flatten())
|
||||||
|
.unwrap_or(start);
|
||||||
|
let trace = lua.create_table()?;
|
||||||
|
trace.set("Hit", false)?;
|
||||||
|
// В GMod `tr.Entity` — NULL-сущность, а не nil: скрипты вызывают у неё
|
||||||
|
// `IsPlayer()`/`IsNPC()`/`Health()` до проверки `IsValid`.
|
||||||
|
let null_entity = lua
|
||||||
|
.globals()
|
||||||
|
.get::<Value>("__sr_null_entity")
|
||||||
|
.unwrap_or(Value::Nil);
|
||||||
|
trace.set("Entity", null_entity)?;
|
||||||
|
trace.set("StartPos", lua.create_userdata(start)?)?;
|
||||||
|
trace.set("HitPos", lua.create_userdata(endpos)?)?;
|
||||||
|
trace.set(
|
||||||
|
"Normal",
|
||||||
|
lua.create_userdata(LuaVector::new(0.0, 0.0, 1.0))?,
|
||||||
|
)?;
|
||||||
|
trace.set("Fraction", 1.0)?;
|
||||||
|
trace.set("Allsolid", false)?;
|
||||||
|
trace.set("StartSolid", false)?;
|
||||||
|
trace.set("HitWorld", false)?;
|
||||||
|
trace.set("HitSky", false)?;
|
||||||
|
trace.set("HitBox", 0)?;
|
||||||
|
trace.set("HitGroup", 0)?;
|
||||||
|
trace.set("MatType", 0)?;
|
||||||
|
trace.set("Contents", 0)?;
|
||||||
|
trace.set("Damage", 0)?;
|
||||||
|
trace.set("SurfaceProps", 0)?;
|
||||||
|
trace.set("PhysicsObject", Value::Nil)?;
|
||||||
|
trace.set("HitTexture", "")?;
|
||||||
|
Ok(trace)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `sound.*` — заглушки: звука у клиента пока нет.
|
||||||
|
fn install_sound(lua: &Lua) -> Result<(), mlua::Error> {
|
||||||
|
let globals = lua.globals();
|
||||||
|
let sound = lua.create_table()?;
|
||||||
|
for name in ["Play", "PlayURL", "Stop", "Add", "Emit"] {
|
||||||
|
sound.set(name, lua.create_function(|_, _: mlua::MultiValue| Ok(()))?)?;
|
||||||
|
}
|
||||||
|
globals.set("sound", sound)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Базовая таблица SWEP и объекты владельца/вьюмодели.
|
||||||
|
fn install_base(lua: &Lua) -> Result<(), mlua::Error> {
|
||||||
|
let globals = lua.globals();
|
||||||
|
lua.load(include_str!("swep_base.lua"))
|
||||||
|
.set_name("swep_base.lua")
|
||||||
|
.exec()?;
|
||||||
|
|
||||||
|
let base: Table = globals.get("__sr_swep_base")?;
|
||||||
|
let meta = lua.create_table()?;
|
||||||
|
meta.set("__index", base)?;
|
||||||
|
globals.set("__sr_swep_meta", meta)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::LuaRuntime;
|
||||||
|
|
||||||
|
fn runtime() -> LuaRuntime {
|
||||||
|
LuaRuntime::new().expect("рантайм")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// База SWEP обязана покрывать методы, на которые опираются реальные
|
||||||
|
/// скрипты: иначе `PrimaryAttack` падает с «attempt to call a nil value».
|
||||||
|
#[test]
|
||||||
|
fn base_swep_methods_exist_and_run() {
|
||||||
|
let runtime = runtime();
|
||||||
|
// Используем настоящий загрузчик на временном каталоге.
|
||||||
|
let dir = std::env::temp_dir().join("source_rs_swep_test");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(dir.join("lua/weapons")).expect("каталог");
|
||||||
|
std::fs::write(
|
||||||
|
dir.join("lua/weapons/weapon_test.lua"),
|
||||||
|
br##"
|
||||||
|
SWEP.PrintName = "#test"
|
||||||
|
SWEP.Spawnable = true
|
||||||
|
SWEP.ViewModel = Model("models/weapons/c_arms.mdl")
|
||||||
|
SWEP.Primary.ClipSize = 10
|
||||||
|
SWEP.Primary.Automatic = true
|
||||||
|
function SWEP:SetupDataTables()
|
||||||
|
self:NetworkVar("Float", 0, "NextIdle")
|
||||||
|
end
|
||||||
|
function SWEP:Initialize()
|
||||||
|
self:SetHoldType("smg")
|
||||||
|
end
|
||||||
|
function SWEP:Deploy()
|
||||||
|
self:SetNextPrimaryFire(CurTime() + 0.2)
|
||||||
|
self:SetNextIdle(CurTime() + 0.5)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
function SWEP:PrimaryAttack()
|
||||||
|
self:SetClip1(self:Clip1() - 1)
|
||||||
|
self:SetNextPrimaryFire(CurTime() + 0.1)
|
||||||
|
end
|
||||||
|
function SWEP:Think()
|
||||||
|
local owner = self:GetOwner()
|
||||||
|
local vm = owner:GetViewModel()
|
||||||
|
local _ = vm:LookupSequence("idle")
|
||||||
|
local tr = util.TraceLine({ start = owner:GetShootPos(),
|
||||||
|
endpos = owner:GetShootPos() })
|
||||||
|
assert(tr.Hit == false)
|
||||||
|
assert(IsValid(tr.Entity) == false)
|
||||||
|
assert(IsValid(owner))
|
||||||
|
assert(game.SinglePlayer())
|
||||||
|
assert(#player.GetAll() >= 0)
|
||||||
|
end
|
||||||
|
"##,
|
||||||
|
)
|
||||||
|
.expect("файл");
|
||||||
|
let (loaded, _, failed) = runtime.load_scripts(&dir);
|
||||||
|
assert_eq!((loaded, failed), (1, 0), "скрипт загружен без ошибок");
|
||||||
|
let clip = runtime
|
||||||
|
.probe("return tostring(weapons.Get('weapon_test'):Clip1())")
|
||||||
|
.expect("probe")
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert_eq!(clip, "10", "стартовый запас из Primary.ClipSize");
|
||||||
|
|
||||||
|
runtime
|
||||||
|
.call_weapon_method("weapon_test", "PrimaryAttack")
|
||||||
|
.expect("PrimaryAttack");
|
||||||
|
let clip = runtime
|
||||||
|
.probe("return tostring(weapons.Get('weapon_test'):Clip1())")
|
||||||
|
.expect("probe")
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert_eq!(clip, "9", "выстрел уменьшил магазин");
|
||||||
|
runtime
|
||||||
|
.call_weapon_method("weapon_test", "Deploy")
|
||||||
|
.expect("Deploy");
|
||||||
|
runtime
|
||||||
|
.call_weapon_method("weapon_test", "Think")
|
||||||
|
.expect("Think");
|
||||||
|
runtime
|
||||||
|
.call_weapon_method("weapon_test", "Holster")
|
||||||
|
.expect("Holster");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trace_and_isvalid_are_safe_without_arguments() {
|
||||||
|
let runtime = runtime();
|
||||||
|
runtime
|
||||||
|
.run(
|
||||||
|
"stubs.lua",
|
||||||
|
br##"
|
||||||
|
local tr = util.TraceLine()
|
||||||
|
assert(tr.Hit == false)
|
||||||
|
assert(IsValid(nil) == false)
|
||||||
|
assert(IsValid(false) == false)
|
||||||
|
assert(IsValid({}) == true)
|
||||||
|
sound.Play("x")
|
||||||
|
EmitSound("x")
|
||||||
|
timer.Simple(0, function() printed = true end)
|
||||||
|
"##,
|
||||||
|
)
|
||||||
|
.expect("заглушки не падают");
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||||
|
runtime.tick().expect("таймеры");
|
||||||
|
let printed = runtime
|
||||||
|
.probe("return tostring(printed)")
|
||||||
|
.expect("probe")
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert_eq!(printed, "true");
|
||||||
|
}
|
||||||
|
}
|
||||||
185
crates/lua/src/swep_base.lua
Normal file
185
crates/lua/src/swep_base.lua
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
-- База SWEP: методы, которые в GMod приходят из base_weapon.
|
||||||
|
--
|
||||||
|
-- Клиент вызывает Initialize/Deploy/Holster/PrimaryAttack/Think прямо у
|
||||||
|
-- таблицы SWEP. Если метода нет, Lua падает с «attempt to call a nil value»;
|
||||||
|
-- поэтому базовые методы обязаны существовать до выполнения скрипта. Свои
|
||||||
|
-- определения из `lua/weapons/*.lua` перекрывают их как обычные поля таблицы.
|
||||||
|
|
||||||
|
__sr_swep_base = {}
|
||||||
|
local base = __sr_swep_base
|
||||||
|
|
||||||
|
-- Состояние вьюмодели, которую видит скрипт (реальный рендер — в клиенте).
|
||||||
|
-- SendViewModelMatchingSequence кладёт сюда имя секвенции: клиент может
|
||||||
|
-- проиграть её, если она есть в модели.
|
||||||
|
local viewmodel = {}
|
||||||
|
__sr_swep_viewmodel = viewmodel
|
||||||
|
|
||||||
|
function viewmodel:SequenceDuration() return 0.5 end
|
||||||
|
function viewmodel:GetPlaybackRate() return self.__playback_rate or 1 end
|
||||||
|
function viewmodel:SetPlaybackRate(rate)
|
||||||
|
self.__playback_rate = tonumber(rate) or 1
|
||||||
|
end
|
||||||
|
function viewmodel:LookupSequence(name) return name or 0 end
|
||||||
|
function viewmodel:GetSequence() return 0 end
|
||||||
|
function viewmodel:SetSequence(_) end
|
||||||
|
function viewmodel:SendViewModelMatchingSequence(sequence)
|
||||||
|
__sr_view_sequence = sequence
|
||||||
|
__sr_view_sequence_time = CurTime()
|
||||||
|
end
|
||||||
|
function viewmodel:GetCycle() return 0 end
|
||||||
|
function viewmodel:SetCycle(_) end
|
||||||
|
function viewmodel:IsValid() return true end
|
||||||
|
function viewmodel:GetModel() return self.__model or "" end
|
||||||
|
function viewmodel:GetOwner() return __sr_swep_owner end
|
||||||
|
function viewmodel:SetNoDraw(_) end
|
||||||
|
function viewmodel:GetNoDraw() return false end
|
||||||
|
|
||||||
|
-- NULL-сущность: в GMod `tr.Entity` не nil, а объект, у которого IsValid()
|
||||||
|
-- ложно. Скрипты (например, медик) вызывают у него методы до проверки.
|
||||||
|
__sr_null_entity = {}
|
||||||
|
function __sr_null_entity:IsValid() return false end
|
||||||
|
function __sr_null_entity:IsPlayer() return false end
|
||||||
|
function __sr_null_entity:IsNPC() return false end
|
||||||
|
function __sr_null_entity:IsWorld() return false end
|
||||||
|
function __sr_null_entity:Health() return 0 end
|
||||||
|
function __sr_null_entity:GetMaxHealth() return 0 end
|
||||||
|
function __sr_null_entity:SetHealth(_) end
|
||||||
|
function __sr_null_entity:GetInternalVariable(_) return nil end
|
||||||
|
function __sr_null_entity:GetPhysicsObject() return nil end
|
||||||
|
function __sr_null_entity:GetPos() return Vector(0, 0, 0) end
|
||||||
|
function __sr_null_entity:GetClass() return "" end
|
||||||
|
function __sr_null_entity:GetName() return "" end
|
||||||
|
|
||||||
|
-- Владелец оружия: в GMod это сущность игрока. У клиента пока нет отдельного
|
||||||
|
-- игрового entity-объекта, поэтому методы возвращают безопасные значения.
|
||||||
|
local owner = {}
|
||||||
|
__sr_swep_owner = owner
|
||||||
|
|
||||||
|
function owner:IsPlayer() return true end
|
||||||
|
function owner:IsNPC() return false end
|
||||||
|
function owner:IsWorld() return false end
|
||||||
|
function owner:IsValid() return true end
|
||||||
|
function owner:Alive() return true end
|
||||||
|
function owner:GetViewModel() return viewmodel end
|
||||||
|
function owner:SetViewModel(_) end
|
||||||
|
function owner:GetActiveWeapon() return __sr_active_weapon end
|
||||||
|
function owner:GetWeapon(_) return nil end
|
||||||
|
function owner:GetHeldWeapon() return nil end
|
||||||
|
function owner:GetWeapons() return {} end
|
||||||
|
function owner:SetAnimation(_) end
|
||||||
|
function owner:LagCompensation(_) end
|
||||||
|
function owner:GetShootPos() return Vector(0, 0, 64) end
|
||||||
|
function owner:EyePos() return Vector(0, 0, 64) end
|
||||||
|
function owner:GetPos() return Vector(0, 0, 64) end
|
||||||
|
function owner:GetAbsOrigin() return Vector(0, 0, 64) end
|
||||||
|
function owner:SetPos(_) end
|
||||||
|
function owner:GetAimVector() return Vector(1, 0, 0) end
|
||||||
|
function owner:GetForward() return Vector(1, 0, 0) end
|
||||||
|
function owner:GetRight() return Vector(0, -1, 0) end
|
||||||
|
function owner:GetUp() return Vector(0, 0, 1) end
|
||||||
|
function owner:GetEyeAngles() return Angle(0, 0, 0) end
|
||||||
|
function owner:GetVelocity() return Vector(0, 0, 0) end
|
||||||
|
function owner:GetMoveVelocity() return Vector(0, 0, 0) end
|
||||||
|
function owner:GetEyeTrace() return util.TraceLine({}) end
|
||||||
|
function owner:Health() return 100 end
|
||||||
|
function owner:GetMaxHealth() return 100 end
|
||||||
|
function owner:SetHealth(_) end
|
||||||
|
function owner:TakeDamage(_) end
|
||||||
|
function owner:GetInternalVariable(_) return nil end
|
||||||
|
function owner:GetPhysicsObject() return nil end
|
||||||
|
function owner:GetInfoNum(_, default) return tonumber(default) or 0 end
|
||||||
|
function owner:GetInfo(_, default) return default or "" end
|
||||||
|
function owner:GetName() return "player" end
|
||||||
|
function owner:Nick() return "player" end
|
||||||
|
function owner:SteamID() return "STEAM_0:0:0" end
|
||||||
|
function owner:SteamID64() return "0" end
|
||||||
|
function owner:UniqueID() return 1 end
|
||||||
|
function owner:EntIndex() return 1 end
|
||||||
|
function owner:GetObserverMode() return 0 end
|
||||||
|
function owner:InVehicle() return false end
|
||||||
|
function owner:IsAdmin() return false end
|
||||||
|
function owner:GetUserGroup() return "player" end
|
||||||
|
function owner:SetNoDraw(_) end
|
||||||
|
function owner:DrawViewModel() return true end
|
||||||
|
function owner:SetFOV(_) end
|
||||||
|
|
||||||
|
-- Основные методы SWEP.
|
||||||
|
|
||||||
|
function base:Initialize() end
|
||||||
|
function base:SetupDataTables() end
|
||||||
|
function base:Deploy() return true end
|
||||||
|
function base:Holster() return true end
|
||||||
|
function base:Think() end
|
||||||
|
function base:PrimaryAttack() end
|
||||||
|
function base:SecondaryAttack() end
|
||||||
|
function base:Reload() end
|
||||||
|
function base:OnDrop() end
|
||||||
|
function base:Draw() end
|
||||||
|
function base:DrawHUD() end
|
||||||
|
|
||||||
|
function base:GetOwner() return __sr_swep_owner end
|
||||||
|
function base:SetOwner(who) __sr_swep_owner = who end
|
||||||
|
function base:GetViewModel() return viewmodel end
|
||||||
|
function base:SetHoldType(hold) self.HoldType = hold end
|
||||||
|
function base:GetHoldType() return self.HoldType or "none" end
|
||||||
|
function base:SetWeaponVisible(_) end
|
||||||
|
function base:IsWeaponVisible() return true end
|
||||||
|
function base:GetModel() return self.WorldModel and self.WorldModel.path or "" end
|
||||||
|
function base:SetModel(_) end
|
||||||
|
|
||||||
|
function base:SetNextPrimaryFire(time)
|
||||||
|
self.__next_primary_fire = tonumber(time) or 0
|
||||||
|
end
|
||||||
|
function base:GetNextPrimaryFire() return self.__next_primary_fire or 0 end
|
||||||
|
function base:SetNextSecondaryFire(time)
|
||||||
|
self.__next_secondary_fire = tonumber(time) or 0
|
||||||
|
end
|
||||||
|
function base:GetNextSecondaryFire() return self.__next_secondary_fire or 0 end
|
||||||
|
function base:SetNextMeleeAttack(time)
|
||||||
|
self.__next_melee_attack = tonumber(time) or 0
|
||||||
|
end
|
||||||
|
function base:GetNextMeleeAttack() return self.__next_melee_attack or 0 end
|
||||||
|
|
||||||
|
-- NetworkVar в GMod создаёт пару GetX/SetX. Значение живёт в таблице SWEP,
|
||||||
|
-- а тип задаёт только значение по умолчанию.
|
||||||
|
function base:NetworkVar(kind, _index, name)
|
||||||
|
local key = "__netvar_" .. name
|
||||||
|
local default = 0
|
||||||
|
if kind == "Bool" then
|
||||||
|
default = false
|
||||||
|
elseif kind == "String" then
|
||||||
|
default = ""
|
||||||
|
elseif kind == "Vector" then
|
||||||
|
default = Vector(0, 0, 0)
|
||||||
|
elseif kind == "Angle" then
|
||||||
|
default = Angle(0, 0, 0)
|
||||||
|
elseif kind == "Entity" then
|
||||||
|
default = nil
|
||||||
|
end
|
||||||
|
self["Set" .. name] = function(state, value) state[key] = value end
|
||||||
|
self["Get" .. name] = function(state)
|
||||||
|
local value = state[key]
|
||||||
|
if value == nil then return default end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function base:Clip1() return self.__clip or 0 end
|
||||||
|
function base:SetClip1(value)
|
||||||
|
self.__clip = math.max(0, math.floor(tonumber(value) or 0))
|
||||||
|
end
|
||||||
|
function base:AddClip1(value) self:SetClip1(self:Clip1() + (tonumber(value) or 0)) end
|
||||||
|
function base:Clip2() return self.__clip2 or 0 end
|
||||||
|
function base:SetClip2(value)
|
||||||
|
self.__clip2 = math.max(0, math.floor(tonumber(value) or 0))
|
||||||
|
end
|
||||||
|
|
||||||
|
function base:EmitSound(_) end
|
||||||
|
function base:StopSound(_) end
|
||||||
|
function base:SendWeaponAnim(_) end
|
||||||
|
function base:ShootEffects() end
|
||||||
|
function base:ShootBullet() end
|
||||||
|
function base:SequenceDuration() return 0.5 end
|
||||||
|
function base:GetSequenceName(_) return "" end
|
||||||
|
function base:GetPhysicsObject() return nil end
|
||||||
|
function base:Remove() end
|
||||||
|
|
@ -60,3 +60,46 @@ fn real_gmod_weapons_and_entities_load() {
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
assert_eq!(entity, "true", "scripted_ents.Get('sent_ball')");
|
assert_eq!(entity, "true", "scripted_ents.Get('sent_ball')");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Реальные SWEP-скрипты вызываются клиентом: Deploy, атака, Think, Holster.
|
||||||
|
///
|
||||||
|
/// Тест важен тем, что не мокает GMod-API, а прогоняет **настоящие** файлы
|
||||||
|
/// оружия из установки: любая отсутствующая функция (`CurTime`, `IsValid`,
|
||||||
|
/// `util.TraceLine`, `GetViewModel`, ...) здесь станет ошибкой Lua.
|
||||||
|
#[test]
|
||||||
|
fn real_gmod_weapon_methods_run_without_errors() {
|
||||||
|
let Some(game) = game_dir() else {
|
||||||
|
eprintln!("пропуск: ассеты Garry's Mod не найдены");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let runtime = LuaRuntime::new().expect("рантайм");
|
||||||
|
let (weapons, _, _) = runtime.load_scripts(&game);
|
||||||
|
assert!(weapons >= 3, "мало оружия: {weapons}");
|
||||||
|
|
||||||
|
for class in ["weapon_fists", "weapon_flechettegun", "weapon_medkit"] {
|
||||||
|
for method in [
|
||||||
|
"SetupDataTables",
|
||||||
|
"Initialize",
|
||||||
|
"Deploy",
|
||||||
|
"PrimaryAttack",
|
||||||
|
"Think",
|
||||||
|
"SecondaryAttack",
|
||||||
|
"Holster",
|
||||||
|
] {
|
||||||
|
runtime
|
||||||
|
.call_weapon_method(class, method)
|
||||||
|
.unwrap_or_else(|err| panic!("{class}:{method}: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Магазин медика: после атаки по пустой трассе он не тратится, но
|
||||||
|
// значение обязано читаться как число (общий контракт Clip1).
|
||||||
|
let clip = runtime
|
||||||
|
.call_weapon_number("weapon_medkit", "Clip1")
|
||||||
|
.expect("Clip1 медика");
|
||||||
|
assert!(clip >= 0.0, "Clip1: {clip}");
|
||||||
|
let clip_size = runtime
|
||||||
|
.weapon_number("weapon_medkit", "Primary.ClipSize")
|
||||||
|
.expect("Primary.ClipSize");
|
||||||
|
assert_eq!(clip_size, 100.0);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue