Lua: поля ENT-скриптов и модель-представитель для спавн-меню

This commit is contained in:
Niko Marmeladkov 2026-10-24 13:33:34 +03:00
parent 70ee8d9ece
commit f41bfde690

View file

@ -113,6 +113,40 @@ impl LuaRuntime {
}
}
/// Строковое поле ENT-скрипта (`Model`, `WorldModel`, `PrintName`, ...).
///
/// Нужен спавн-меню: список `SpawnableEntities` хранит только имена, а
/// модель сущности лежит в её таблице. Как и у оружия, `Model("...")`
/// приходит таблицей с полем `path`.
pub fn entity_string(&self, class: &str, field: &str) -> Option<String> {
let globals = self.lua.globals();
let store: Table = globals.get("__sr_ents").ok()?;
let Value::Table(ent) = store.get::<Value>(class).ok()? else {
return None;
};
match ent.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,
}
}
/// Булево поле ENT-скрипта (в спавн-меню — `Spawnable`).
///
/// `nil` и не-булевы значения дают `None`: фильтр меню отличает
/// «явно false» от «поле не задано».
pub fn entity_bool(&self, class: &str, field: &str) -> Option<bool> {
let globals = self.lua.globals();
let store: Table = globals.get("__sr_ents").ok()?;
let Value::Table(ent) = store.get::<Value>(class).ok()? else {
return None;
};
match ent.get::<Value>(field) {
Ok(Value::Boolean(value)) => Some(value),
_ => None,
}
}
/// Загружает скрипты GMod: `lua/weapons/*.lua` и `lua/entities/*.lua`.
///
/// Каждый скрипт выполняется со свежей таблицей `SWEP`/`ENT` (у оружия
@ -166,6 +200,16 @@ impl LuaRuntime {
failed += 1;
continue;
}
// Модель спавн-меню: у ENT-скриптов её часто задают только в
// коде (`self:SetModel("models/...")`), а не полем `ENT.Model`.
// Запоминаем первый литерал `models/*.mdl` — он и будет
// представителем сущности в списке спавна.
if kind == "entity"
&& let Some(model) = find_model_literal(&source)
&& let Err(err) = table.set("__sr_preview_model", model)
{
eprintln!("скрипт {}: модель для спавна: {err}", file.display());
}
// GMod-синтаксис (!, &&, ||) приводится препроцессором —
// без него скрипты падают на первой же строке с `!`.
if let Err(err) = self.run(name, source.as_bytes()) {
@ -742,6 +786,47 @@ pub fn preprocess(source: &str) -> String {
out
}
/// Первый литерал `models/*.mdl` в исходнике ENT-скрипта.
///
/// Спавн-меню нужно чем-то показать сущность, у которой модель задаётся
/// вызовом `SetModel` в `Initialize`, а не полем `ENT.Model`. Поиск
/// ограничен: путь короче 256 байт, только до кавычки/пробела/закрывающей
/// скобки — данные из файла не должны влиять на границы среза.
fn find_model_literal(source: &str) -> Option<String> {
const PREFIX: &[u8] = b"models/";
const MAX_PATH: usize = 256;
let bytes = source.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
if i + PREFIX.len() <= bytes.len()
&& bytes[i..i + PREFIX.len()].eq_ignore_ascii_case(PREFIX)
{
let mut end = i + PREFIX.len();
while end < bytes.len() && end - i < MAX_PATH {
let b = bytes[end];
if b.is_ascii_whitespace() || matches!(b, b'"' | b'\'' | b')' | b',' | b';' | 0) {
break;
}
end += 1;
}
let Some(candidate) = source.get(i..end) else {
// Лимит длины мог разрезать многобайтовый символ: пропускаем.
i += 1;
continue;
};
if candidate.len() > PREFIX.len()
&& candidate
.get(candidate.len().saturating_sub(4)..)
.is_some_and(|ext| ext.eq_ignore_ascii_case(".mdl"))
{
return Some(candidate.to_string());
}
}
i += 1;
}
None
}
/// Если на позиции длинная скобка `[=*[`, возвращает её длину.
fn long_bracket(bytes: &[u8], at: usize) -> (usize, ()) {
if bytes.get(at) != Some(&b'[') {
@ -889,4 +974,51 @@ local t = [[raw ! text]]
assert_eq!(out[4], "5 0 0", "поле записывается");
assert_eq!(out[5], "10", "math.Clamp");
}
/// Реестр ENT-скриптов отдаёт поля, по которым строится спавн-меню:
/// класс, `Spawnable` и модель.
#[test]
fn entity_registry_exposes_spawn_fields() {
let rt = LuaRuntime::new().expect("рантайм");
rt.run(
"sent_test.lua",
br#"
local ENT = {
Type = "sent_test",
Spawnable = true,
Model = Model("models/props_junk/watermelon01.mdl"),
}
scripted_ents.Register(ENT, "sent_test")
"#,
)
.expect("скрипт");
assert_eq!(
rt.entity_string("sent_test", "Model").as_deref(),
Some("models/props_junk/watermelon01.mdl")
);
assert_eq!(rt.entity_bool("sent_test", "Spawnable"), Some(true));
assert_eq!(rt.entity_bool("sent_test", "AdminOnly"), None);
assert_eq!(rt.entity_string("нет_такого", "Model"), None);
}
/// Модель `SetModel` в коде ENT-скрипта находится без разбора Lua:
/// только литерал, с границами и лимитом длины.
#[test]
fn model_literal_from_entity_source() {
let src = r#"function ENT:Initialize() self:SetModel( "models/Combine_Helicopter/helicopter_bomb01.mdl" ) end"#;
assert_eq!(
find_model_literal(src).as_deref(),
Some("models/Combine_Helicopter/helicopter_bomb01.mdl")
);
assert_eq!(find_model_literal("no models here"), None);
// Обрывок без расширения не считается моделью.
assert_eq!(
find_model_literal("SetModel('models/props_junk/a b.mdl')"),
None
);
assert_eq!(
find_model_literal("prefab = 'models/props_junk/popcan01a.mdl'"),
Some("models/props_junk/popcan01a.mdl".to_string())
);
}
}