Создавайте гибкие и высокопроизводительные системы моддинга

В постоянно развивающемся мире разработки игр моддинг стал мощным способом улучшить игровой процесс и вдохнуть новую жизнь в существующие игры. Это позволяет создателям и игрокам воплощать в жизнь свои уникальные идеи, обогащая игровую экосистему свежим контентом и безграничными возможностями. Как разработчики, мы всегда ищем инновационные способы создания и поддержки систем моддинга, которые были бы одновременно эффективными и удобными для пользователя. Сегодня мы отправляемся в увлекательное путешествие, чтобы исследовать синергию между двумя замечательными технологиями, Lua и Rust, погружаясь в мир систем моддинга.

Lua, легкий и универсальный язык сценариев, долгое время был популярным выбором среди разработчиков игр для реализации поддержки моддинга. Его гибкость и простота использования делают его идеальным кандидатом для расширения функциональности игры и создания пользовательского контента. С другой стороны, Rust, язык системного программирования, ориентированный на безопасность и производительность, быстро набрал обороты благодаря своим мощным функциям и впечатляющим возможностям. Объединив лучшее из обоих миров, мы решили создать надежную, но доступную систему моддинга, которая открывает двери для бесконечного творчества.

Присоединяйтесь к нам, пока мы экспериментируем с уникальными возможностями Lua и Rust, делясь своими знаниями, проблемами и победами на этом пути. Являетесь ли вы опытным разработчиком или только начинаете свой путь в разработке игр, это приключение обещает стать захватывающим исследованием систем моддинга и потенциала Lua и Rust.

Делаем грузовой проект и интегрируем Lua

Давайте отправимся в это путешествие, создав новый проект Cargo и настроив простой сценарий Lua.

Сначала создайте новый проект Cargo и перейдите в каталог проекта:

cargo new modding-example && cd modding-example

Затем добавьте крейт rlua в свой проект:

cargo add rlua

Теперь давайте создадим файл main.rs со следующим кодом для выполнения Lua-скрипта:

// File: src/main.rs
use rlua::{Lua, Result};
use std::fs;

fn exec_lua_code() -> Result<()> {
    let lua_code = fs::read_to_string("game/main.lua").expect("Unable to read the Lua script");

    let lua = Lua::new();
    lua.context(|lua_ctx| {
        lua_ctx.load(&lua_code).exec()?;

        Ok(())
    })
}

fn main() -> Result<()> {
    exec_lua_code()
}

Если мы попытаемся запустить это, мы заметим, что скрипт не может быть выполнен, потому что файл game/main.lua еще не существует. Создадим необходимую директорию и файл:

mkdir game
touch game/main.lua

Теперь давайте добавим изюминку в наш Lua-скрипт, распечатав некоторую информацию:

-- File: game/main.lua
print(_VERSION)
print("🌙 Lua is working!")

С этой настройкой вы должны увидеть версию Lua и сообщение «Lua работает!» сообщение, выводимое на консоль при запуске программы на Rust.

Делаем Stdout более интересным

Наши тексты выглядят просто, и мы не можем отличить вывод lua от вывода ржавчины. Давайте изменим это, используя цвета из популярного ящика colored.

Во-первых, давайте добавим в наш проект ящик colored:

cargo add colored

Теперь давайте повторно реализуем оператор Lua print с нашим собственным.

-- File: game/main.lua

function print(...)
  local args = {...}
  
  for _, arg in ipairs(args) do
    if type(arg) == "table" then
      __rust_bindings_print(tostring(arg))
    elseif type(arg) == "string" then
      __rust_bindings_print(arg)
    else
      __rust_bindings_print(tostring(arg))
    end
  end
end

-- rest of game/main.lua

Теперь нам нужно передать __rust_bindings_print контексту lua, чтобы он вызывал наш код rust. Мы также собираемся создать функцию log для нашей среды выполнения rust:

// File: src/main.rs
use rlua::{Lua, Result};
use std::fs;

mod logger;

/* rest of main.rs */

И теперь мы должны создать этот файл:

touch src/logger.rs

Наконец, мы можем реализовать раскрашенный вывод:

// File: src/logger.rs
use colored::*;
use rlua::{Result, Value};
use std::io::Write;

pub fn lua_print<'lua>(lua_ctx: rlua::Context<'lua>, value: Value<'lua>) -> Result<()> {
    let mut str = String::from("nil");
    if let Some(lua_str) = lua_ctx.coerce_string(value)? {
        str = lua_str.to_str()?.to_string();
    }

    match writeln!(std::io::stdout(), "[{}] {}", "lua".cyan(), str) {
        Ok(_) => Ok(()),
        Err(e) => Err(rlua::Error::external(e)),
    }
}

pub fn log(message: &str) {
    println!("[{}] {}", "rust".red(), message);
}

Теперь мы можем использовать эти функции в нашем src/main.rs

// File: src/main.rs
use logger::{log, lua_print}; // new line
// rest of use statements

// ...

fn exec_lua_code() -> Result<()> {
    let lua_code = fs::read_to_string("game/main.lua").expect("Unable to read the Lua script");
    
    let lua = Lua::new();
    lua.context(|lua_ctx| {
        log("🔧 Loading Lua bindings");
        lua_ctx
            .globals()
            .set("__rust_bindings_print", lua_ctx.create_function(lua_print)?)?;        
        lua_ctx.load(&lua_code).exec()?;

        Ok(())
    })
}

// ...

Наш цветной вывод теперь работает! И теперь мы можем легко различать наши среды выполнения rust и lua.

Делаем структуру модов

Давайте на секунду задумаемся о том, как будут выглядеть наши моды.

На ум приходит следующая структура:

/game
|--/mods
  |--/base
  |----mod.json
  |----mod.lua
  |--/dlc
  |----mod.json
  |----mod.lua
|--main.lua

Файл .json будет иметь следующую схему:

{
  "name": "base",
  "version": "0.0.1",
  "description": "A mod for the base game.",
  "author": "Stefan Kupresak"
}

Загрузка модов в Rust

Подобно тому, как мы создали модуль logger, теперь мы собираемся создать модуль ржавчины mods:

touch src/mods.rs

И добавляем ссылку на него в наш main.rs

// File: src/main.rs
mod mods;

Для этого модуля нам также потребуется добавить несколько новых грузовых зависимостей, которые помогут нам проанализировать файлы манифеста мода json:

cargo add serde_json

А для serde мы собираемся вручную добавить его в Cargo.toml.

[dependecies]
...
serde = { version = "*", features = ["derive"] }

Теперь мы можем реализовать модуль mods.rs:

// File: src/mods.rs
use std::fs;
use rlua::{MetaMethod, UserData, UserDataMethods};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Clone)]
pub struct Mod {
    pub name: String,
    pub version: String,
    pub description: String,
    pub author: String,
}

/**
 * UserData is used from rlua so 
 * we can pass the Vec<Mod> to our 
 * Lua context
**/
impl UserData for Mod {}

/**
 * This helps us later to convert
 * the mods struct to a lua table
**/
pub fn items_to_lua_table<'lua>(lua_ctx: &Context<'lua>, items: Vec<Mod>) -> Result<Table<'lua>> {
    let table = lua_ctx.create_table()?;
    for (i, item) in items.iter().enumerate() {
        // lua tables start from index 1 :)
        // see: https://www.tutorialspoint.com/why-do-lua-arrays-tables-start-at-1-instead-of-0
        table.set(i + 1, item.clone())?;
    }
    Ok(table)
}

fn list_mods_root() -> Vec<String> {
    let mut mods = vec![];

    for entry in fs::read_dir("game/mods").expect("Unable to read the mods directory") {
        let entry = entry.expect("Unable to read the mods directory");
        let path = entry.path();

        if path.is_dir() {
            let mod_json_path = path.join("mod.json");
            if mod_json_path.exists() {
                mods.push(mod_json_path.to_str().unwrap().to_string());
            }
        }
    }

    mods
}

pub fn load() -> Vec<Mod> {
    let mod_paths = list_mods_root();
    let mut mods = vec![];

    for mod_json_path in mod_paths {
        let mod_json = fs::read_to_string(mod_json_path).expect("Unable to read the mod.json file");
        let mod_json = serde_json::from_str(&mod_json).expect("Unable to parse the mod.json file");
        mods.push(mod_json);
    }

    mods
}

Захватывающий! Теперь мы можем загрузить моды из нашего файла main.rs.

// File src/main.rs
fn main() -> Result<()> {
  let mods = mods::load();
  log(format!("Loaded {} mods", mods.len()).as_str());
  exec_lua_code();
}

Теперь у нас должен быть следующий вывод:

[rust] Loaded 2 mods
[rust] 🔧 Loading Lua bindings
[lua] Lua 5.4
[lua] 🌙 Lua is working!

Имейте в виду, что наш каталог game/ выглядит так:

Передача нашего вектора модов в Lua

Все идет нормально. Наконец, нам нужно передать наш вектор в lua и начать строить «каркас» для загрузки этих модов.

Во-первых, давайте передадим наши моды нашей функции exec_lua_code:

// File src/main.rs
fn exec_lua_code(mods: Vec<Mod>) -> Result<()> {
  /* function body */
}

fn main() -> Result<()> {
  let mods = mods::load();
  /* ... */
  exec_lua_code(mods)
}

Теперь мы можем передать нашу структуру в контекст Lua:

// File src/main.rs
fn exec_lua_code(mods: Vec<Mod>) -> Result<()> {
  /* ... */
  lua.context(|lua_ctx| {
    /* ... */
    let mods_table = mods::items_to_lua_table(&lua_ctx, mods)?;
    lua_ctx.globals().set("mods", mods_table)?;
    /* ... */
  })
}

Мы можем убедиться, что это работает, выполнив следующие действия в нашем main.lua

print("number of mods -> " .. #mods)

Пользовательские данные и Lua

Однако есть одна странная вещь: если мы попытаемся получить доступ к конкретному моду, мы получим эту конструкцию userdata.

print("first mod: ", mods[1]) -- userdata 0x5587a65d49e8

Это специальная конструкция в lua, указывающая на блок необработанной памяти. Что интересно, мы не можем получить к ней доступ как к таблице, как вы ожидаете:

print("first mod name: ", mods[1].name) -- throws an error

Это происходит потому, что lua не знает, как индексировать эту структуру данных. Чтобы сделать его индексируемым, мы должны реализовать функцию __index в его метатаблице.

rlua добавляет удобный способ сделать это. Реализуя трейт UserData, мы можем расширить эти пользовательские данные и добавить этот метаметод, который позволит нам индексировать эти данные в Lua:

// File: src/mods.rs
impl UserData for Mod {
    fn add_methods<'lua, M: UserDataMethods<'lua, Self>>(methods: &mut M) {
        methods.add_meta_method(MetaMethod::Index, |_, modd: &Mod, key: String| {
            match key.as_str() {
                "name" => Ok(modd.name.clone()),
                "version" => Ok(modd.version.clone()),
                "description" => Ok(modd.description.clone()),
                "author" => Ok(modd.author.clone()),
                _ => Err(rlua::Error::external(format!("Unknown key: {}", key))),
            }
        })
    }
}

Теперь, если мы попробуем ту же самую строку еще раз, она просто сработает!

print("first mod name: ", mods[1].name)
[lua] first mod name: 
[lua] base

Создание небольшого игрового фреймворка Lua

Когда часть ржавчины завершена, теперь мы можем расширить сценарий Lua и создать несколько файлов mod.lua, чтобы поэкспериментировать с нашей новой системой.

В этой системе я решил иметь один файл записи mod.lua для каждого мода и сделать его модулем.

-- File: game/main.lua

-- rest of main.lua file

Game = {}
function Game:new()
  o = {}
  self.__index = self
  return setmetatable(o, self)
end

function Game:load()
  -- Starts loading the game
  print("⚡ Loading game")

  -- Load the game mods
  print("🚧 Loading " .. #mods .. " mod(s)")

  for _, mod in ipairs(mods) do
    print("✅ Loading mod " .. mod.name)
    local f, err = loadfile("game/mods/" .. mod.name .. "/mod.lua")

    if f then
      m = f()

      if m and m.init then
        m:init(mod)
      else
        print("❌ Mod " .. mod.name .. " does not have an init function")
      end
    else
      print("❌ Mod " .. mod.name .. " failed to load: " .. err)
    end
  end
end

game = Game:new()
game:load()

Имейте в виду, что этот пример работает со следующим файлом game/mods/base/mod.lua

-- File: game/mods/base/mod.lua
mod = {}

function mod:init(mod) 
  print("🕹️ Initializing mod " .. mod.name)
end

return mod

Будет производить:

➜ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/modding-example`
[rust] 🔧 Loading Lua bindings
[rust] 🚀 executing lua script
[lua] ⚡ Loading game
[lua] 🚧 Loading 2 mod(s)
[lua] ✅ Loading mod base
[lua] 🕹️ Initializing mod base
[lua] ✅ Loading mod dlc
[lua] ❌ Mod dlc does not have an init function

Краткое содержание

В этой статье мы исследуем мощную комбинацию Lua и Rust для создания гибкой и высокопроизводительной системы моддинга для игр. Мы рассмотрим настройку нового проекта Cargo и интеграцию Lua, а затем разработаем четкую и организованную структуру мода. Используя сильные стороны обеих технологий, мы демонстрируем, как Lua и Rust могут работать вместе, чтобы обеспечить бесконечное творчество и вдохнуть новую жизнь в игры с помощью пользовательского контента.

🎊🎊 Поздравляем, если вы зашли так далеко! Вы успешно вошли в мир Lua и Rust, создав прочную основу для гибкой и высокопроизводительной системы моддинга. Продолжая исследовать и экспериментировать, вы, несомненно, откроете для себя еще больше захватывающих возможностей и идей для улучшения ваших игровых проектов. Путешествие, в которое вы отправились, — это только начало — потенциал совместной работы Lua и Rust огромен, и нам не терпится увидеть, что вы создадите дальше. Продолжайте раздвигать границы и счастливого моддинга!

У этой статьи есть репозиторий GitHub с открытым исходным кодом.