"Модульность" проектов и сообщество разработчиков SA:MP

Это один из первых проектов для GTA:SA который разбит на модули. Специально для этого я даже писал свой простенький луа бандлер - LuBu (GitHub). Вам может показаться что это какой то бред, но в не столь большом сообществе lua-скриптеров GTA:SA почему-то всегда было принято писать весь код в одном файле, даже если он занимает тысячи или десятки тысяч строк. Вероятно, это связано с аудиторией этой прекрасной игры, ведь когда двенадцатилетний Вова начинает писать свой мега-супер-пупер чит, то его абсолютно не волнует читабельность и удобство редактирования кода.

Если вам не жалко вашу психику, то можете посмотреть на один из самых ужасных, и, в то же время популярных проектов - Mono Tools (к счастью, автор этого проекта решил забросить скриптинг). Скрипт весит больше полутора мегабайта, так что я просто оставлю здесь скриншот фрагмента кода

Скрытый текст

Итак, теперь перейдем непосредственно к "играм".

Defense Of The Ghetto

Исходный код (старая версия) (прошу отнестись к этому позору с пониманием)

Около двух лет назад, шутки ради, я начал разрабатывать проект под названием Defense Of The Ghetto на базе GTA:SA с помощью MoonLoader, который был пародией на одну из самых популярных игр - Defense Of The Ancients 2 (Dota2). За неделю я написал коряво работающий прототип, в нем были реализованы: персонажи и их способности, предметы, крипы, башни, анимации атаки, нанесение урона и прочее. Из-за некоторых обстоятельств я был вынужден забросить проект на какое-то время. После того как я решил продолжить писать этот проект я понял что старый код просто ужасен и неудобен, из-за чего я начал переписывать его с нуля.

После рефакторинга старого кода были добавлены более удобные методы для взаимодействия со скриптом. Например, для добавления нового персонажа было достаточно всего лишь создать новый .lua скрипт, который возвращал таблицу с параметрами персонажа и закинуть его в папку DOTA\heroes , вот пример персонажа Shadow Fiend:

local AbilityType = require('dota.types').AbilityType;
local Map = require('dota.map');

local function coil(range)
    clearCharTasksImmediately(PLAYER_PED);
    if not hasAnimationLoaded('carry') then
        requestAnimation('carry');
    end
    clearCharTasksImmediately(PLAYER_PED)
    taskPlayAnim(PLAYER_PED, 'putdwn105', 'carry', 0, false, true, true, true, 10000)

    local x, y, z = Map.getPosFromCharVector(range);
    local smoke = createObject(18686, x, y, z - 1);
    Map.dealDamageToPoint(Vector3D(x, y, z));
    wait(3000);
    deleteObject(smoke);
end

local abilities = {}
for i = 3, 9, 3 do
    table.insert(abilities, {
        name = ('Coil (%d)'):format(i),
        manaRequired = 50,
        cooldown = 10,
        useThread = true,
        type = AbilityType.INSTANT,
        onUse = function()
            coil(i);
        end
    });
end
table.insert(abilities, {
    name = 'ULT',
    manaRequired = 50,
    cooldown = 10,
    useThread = true,
    type = AbilityType.INSTANT,
    onUse = function()
        local start = os.clock()
        local ultimate_objects = {}
        
        for i = 0, 360, 30 do
            local angle = math.rad(i) + math.pi / 2
            local posX, posY, posZ = getCharCoordinates(PLAYER_PED)

            local start = Vector3D(1 * math.cos(angle) + posX, 1 * math.sin(angle) + posY, posZ - 1)
            local stop = Vector3D(20 * math.cos(angle) + posX, 20 * math.sin(angle) + posY, posZ - 1)
            local handle = createObject(18686, start.x, start.y, start.z)
            ultimate_objects[handle] = {
                start = start,
                stop = stop
            }
            
        end

        -- create object
        while start + 4 - os.clock() > 0 do
            wait(0)
            for handle, data in pairs(ultimate_objects) do
                if doesObjectExist(handle) then
                    slideObject(handle, data.stop.x, data.stop.y, data.stop.z, 0.5, 0.5, 0.5, false)
                    local result, x, y, z = getObjectCoordinates(handle);
                    if result then
                        Map.dealDamageToPoint(Vector3D(x, y, z), 55);
                    end
                end
            end
        end
        for handle, data in pairs(ultimate_objects) do
            if doesObjectExist(handle) then
                deleteObject(handle)
                ultimate_objects[handle] = nil
            end
        end
    end
});

---@type Hero
local shadow_fiend = {
    type = require('dota.types').HeroType,
    name = 'Shadow Fiend',
    model = 5,
    abilities = abilities,
    stats = {
        maxHealth = 1300,
        maxMana = 200,
        healthRegen = 2,
        manaRegen = 3,
        damage = 10,
        attackSpeed = 10,
        speed = 0,
        attackRange = 1,
    }
};

return shadow_fiend;

Подгрузка персонажей в свою очередь выглядела так:

---@meta
local Utils = require('dota.utils');
local mimgui = require('mimgui');
local ffi = require('ffi');

local PLACEHOLDER_IMAGE = '';
local Heroes = {
    basePath = getWorkingDirectory() .. '\\dota\\heroes',
    list = {}
};

--- Load heroes images (avatars) and abilities icons
--- CALL IN mimgui.OnInitialize
function Heroes.loadImages()
    for codename, data in pairs(Heroes.list) do
        local imageBase85 = data.imageBase85 or PLACEHOLDER_IMAGE;
        Heroes.list[codename].image = imgui.CreateTextureFromFileInMemory(imgui.new('const char*', imageBase85), #imageBase85);

        --// abilities
        for abilityCodename, ability in pairs(data.abilities) do
            local imageBase85 = data.imageBase85 or PLACEHOLDER_IMAGE;
            Heroes.list[codename].abilities[abilityCodename] = imgui.CreateTextureFromFileInMemory(imgui.new('const char*', imageBase85), #imageBase85);
        end
    end
end

--- Load heroes list in <HEROES>.list
function Heroes.init()
    for _, fileName in pairs(Utils.getFilesInPath(Heroes.basePath, '*.lua')) do
        if (fileName ~= 'init.lua') then
            print(fileName);
            local codeName = fileName:gsub('.lua', '');
            Heroes.list[codeName] = require('dota.heroes.' .. codeName);
            print('hero loaded', codeName);
        end
    end
end

return Heroes;

К сожалению (или к счастью), проект был заброшен в связи с осенним призывом, а спустя год я решил не возвращаться к этой идее. Исходный код новой версии был утерян.

Plants Vs Zombies

Проект на GitHub: https://github.com/chaposcripts/gta-sa-plants-vs-zombies.

Краткая предыстория

Во время прохождения срочки, прервавшей разработку DOTG у меня было довольно много времени подумать, так и пришла в голову идея написать данное "чудо". Имея при себе блокнот и карандаш я уже тогда начал накидывать наработки, к слову, ни одна из них не пригодилась.

Изначально данную идею я берег на конкурс, проходящий на самом популярном форуме в сфере SA:MP, однако из-за некоторых обстоятельств конкурс в 2024 году был отменен. Вам может показаться что эта идея слишком слаба для участия в каких-либо конкурсах, однако конкуренция там не велика, сумма призовых в 2023 году достигла более 500.000 рублей, а в 2022 году я занял призовое место выпустив Subway-CJ - пародию на игру Subway Surfers в GTA:SA.

Классы объектов и сущностей

Так как MoonLoader API из коробки не дружит с ООП, разработку пришлось начать с создания псевдоклассов для более удобного взаимодействия с объектами и персонажами. Изначально функции создания и изменения параметров объекта выглядели так:

Object object = createObject(Model modelId, float atX, float atY, float atZ)  -- 0107
setObjectHeading(Object object, float angle)  -- 0177
setObjectRotation(Object object, float rotationX, float rotationY, float rotationZ)  -- 0453
setObjectPathSpeed(int int1, int int2)  -- 049E
setObjectPathPosition(int int1, float float2)  -- 049F
setObjectScale(Object object, float scale)  -- 08D2
bool result = setObjectCoordinates(Object object, float atX, float atY, float atZ)  -- 01BC
setObjectVelocity(Object object, float velocityInDirectionX, float velocityInDirectionY, float velocityInDirectionZ)  -- 0381
setObjectCollision(Object object, bool collision)  -- 0382

С помощью метатаблиц я создал "класс" Object, с помощью которого мне стало гораздо проще взаимодействовать с созданными объектами

object.lua
local Vector3D = require('vector3d');
local Object = {};
local pool = {};

addEventHandler('onScriptTerminate', function(scr)
    if (scr == thisScript()) then
        for k, v in ipairs(pool) do
            v:destroy();
        end
    end
end);

---@class Object
---@field handle number
---@field tag string
---@field setCollision fun(self: Object, collision: boolean)
---@field destroy fun(self: Object)
---@field setScale fun(self: Object, scale: number)
---@field setRotation fun(self: Object, rotation: Vector3D)
---@field setPosition fun(self: Object, position: Vector3D)

setmetatable(Object, {__call = function(t, ...)
    return t:new(...)
end});

function Object:setCollision(bool)
    self.collision = bool;
    setObjectCollision(self.handle, bool);
end

function Object:setRotation(rotation)
    self.rotation = rotation;
    setObjectRotation(self.handle, rotation.x, rotation.y, rotation.z);
end

function Object:setPosition(pos)
    self.pos = pos;
    setObjectCoordinates(self.handle, pos.x, pos.y, pos.z);
end

function Object:destroy()
    deleteObject(self.handle);
    print('Object:destroy(), handle =', self.handle);
    for index, handle in ipairs(pool) do
        if (handle == self.handle) then
            table.remove(pool, index);
        end
    end
end

function Object:setScale(scale)
    self.scale = scale;
    setObjectScale(self.handle, scale);
end

function Object:new(model, pos, rotation, collision, scale, tag)
    local handle = createObject(model, pos.x, pos.y, pos.z)
    assert(doesObjectExist(handle), 'Error creating object.');
    if (rotation) then
        setObjectRotation(handle, rotation.x, rotation.y, rotation.z);
    end
    setObjectCollision(handle, collision);
    setObjectScale(handle, scale or 1);
    print('Object:new(), handle = ', handle);
    local instance = {
        handle = handle,
        tag = tag or '',
        scale = scale or 1,
        collision = collision,
        rotation = rotation or Vector3D(0, 0, 0)
    };
    local meta = setmetatable(instance, {__index = self})
    table.insert(pool, meta);
    return meta;
end

return Object;

Позже данный класс я использовал в "модуле" Map, который был создан для взаимодействия с игровой картой.

local Object = require('object');
...
function Map.destroy()
    ...
    print('deleting all objects, count:', #Map.pool);
    for _, object in pairs(Map.pool) do
        print('deleting obj', object.handle);
        object:destroy();
    end
end

function Map.init()
    -- Create ground (grass)
    table.insert(Map.pool, Object:new(19550, Map.pos, Vector3D(0, 0, 0), true, 1, 'floor'));
    
    -- Create house
    table.insert(Map.pool, Object:new(3639, Vector3D(Map.pos.x - 15, Map.pos.y + 10, Map.pos.z), Vector3D(0, 0, 90), true, 1, 'house'));

    -- Create grid
    for line = 1, 5 do
        for section = 1, 9 do
            if ((line % 2 == 0 and section % 2 == 0) or (line % 2 == 1 and section % 2 == 1)) then -- (line % 2 == section % 2) then
                table.insert(
                    Map.pool,
                    Object:new(
                        19790,
                        Vector3D(Map.pos.x + GRID_SIZE * (section - 1), Map.pos.y + GRID_SIZE * (line - 1), Map.pos.z - 4.9),
                        Vector3D(0, 0, 0),
                        false,
                        1,
                        'floor' .. line .. section
                    )
                );
            end
        end
    end
end
...

Результат:

Далее я приступил к разработке класса Enemy, который бы облегчил взаимодействие с врагами.

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

---@enum EnemyType
local EnemyType = {
    Default = 1
};

---@class EnemyData
---@field name string
---@field model number
---@field maxHealth number
---@field attackAnimation? Animation
---@field attackInterval? number
---@field weapon? number
---@field animationSpeed? number
---@field lastAttackTime? number

---@type table<EnemyType, EnemyData>
local EnemyData = {
    [EnemyType.Default] = {
        model = 267,
        name = 'placeholder',
        maxHealth = 100
    }
};

Далее был создан метод init, который подгружал используемые модели персонажей и оружия, а так же анимации. Так же в методе init был добавлен обработчик выгрузки скрипта для удаления всех персонажей если скрипт завершился с ошибкой или просто был выгружен. Вероятно, более элегантным решением было бы добавить функцию destroy(), а обработчик запихнуть в главный файл проекта, однако эта идея пришла мне в голову после окончания разработки.

function Enemies.init()
    for _, v in pairs(EnemyData) do
        if (not hasModelLoaded(v.model)) then
            requestModel(v.model);
            loadAllModelsNow();
            print('Model', v.model, 'was loaded.');
        end
    end

    addEventHandler('onScriptTerminate', function(scr)
        if (scr == thisScript()) then
            for _, v in ipairs(Enemies.pool) do
                v:destroy();
                print('Destroyed enemy', v.handle);
            end
        end
    end);
end

После создания основных методов я приступил к написанию "класса" Enemies.Enemy. В данном классе были созданы такие методы как:

  • new() - создание нового "врага"

    ---@param type EnemyType
    ---@param line number
    ---@param disableMovement? boolean
    function Enemies.Enemy:new(type, line, disableMovement)
        assert(EnemyData[type], 'Unknown enemy type: ' .. type);
        local instance = Utils.copyTable(EnemyData[type]);
        local spawnPos = Map.getGridPos(line, 10);
        spawnPos.z = spawnPos.z + 1;
        local ped = createChar(4, instance.model, spawnPos.x, spawnPos.y, spawnPos.z - 2); ---@diagnostic disable-line
        freezeCharPosition(ped, false);
        -- taskWanderStandard(ped);
        clearCharTasksImmediately(ped);
        setCharCoordinates(ped, getCharCoordinates(ped));
        setCharHeading(ped, 90);
        instance.x = spawnPos.x;
        instance.lastXUpdate = os.clock();
        instance.health = instance.maxHealth;
        instance.handle = ped; ---@diagnostic disable-line
        instance.line = line;
        instance.lastAttack = os.clock();
        instance.route = {
            from = spawnPos,
            to = Map.getGridPos(line, 0)
        };
        instance.grid = {
            line = line
        };
        instance.spawnedAt = os.clock();
        instance.disableProcess = disableMovement;
        -- instance.startDist = self:getDistanceToFinish();
        if (not disableMovement) then
            -- taskCharSlideToCoord(ped, 0, 0, 0, 0, 1);
        end
        local newMeta = setmetatable(instance, {__index = self});
        newMeta.startDist = newMeta:getDistanceToFinish();
        table.insert(Enemies.pool, newMeta);
        Utils.msg('new enemy spawned, pool len:', #Enemies.pool);
        return newMeta;
    end

  • destroy() - полное удаление персонажа

    function Enemies.Enemy:destroy()
        if (doesCharExist(self.handle)) then
            deleteChar(self.handle);
            print('Enemy was destroyed, handle = ', self.handle);
        end
    end

  • process() - обработка логики действий персонажа (ходьба, поиск ближайшей цели, установка анимаций и т.д.)

    function Enemies.Enemy:process()
        local newPos, changed = Utils.bringVec3To(self.route.from, self.route.to, self.spawnedAt, 5);
        self:setCoordinates(newPos);
        if (not changed) then print('Enemies.Enemy:process()', newPos) end
        -- Some logic here
    end

  • death() - скрытие персонажа за пределы экрана игрока для дальнейшего удаления

    function Enemies.Enemy:death()
        setCharCoordinates(self.handle, 0, 0, -100);
        ...
    end

  • setCoordinates() - изменение координат персонажа, использовалось для имитации движения. Данный метод пришлось написать так как разработчик SA:MP "выключил мозги" всем создаваемым NPC. Встроенная функция setCharCoordinates при телепорте сбрасывает анимацию персонажа, поэтому мой метод телепорта был единственным более-менее адекватным решением проблемы

    ---@param pos Vector3D
    function Enemies.Enemy:setCoordinates(pos)
        pos.z = Map.pos.z + 1;
        local ptr = getCharPointer(self.handle);
        if (not ptr) then
            return print('WARNING, unable to set entity coordinates, ptr == nil, handle =', self.handle);
        end
    
        local matrixPtr = readMemory(ptr + 0x14, 4, false);
        if (matrixPtr == 0) then
            return print('WARNING, unable to set entity coordinates, matrix pointer == nil, handle =', self.handle);
        end
        
        local posPtr = matrixPtr + 0x30;
        writeMemory(posPtr + 0, 4, representFloatAsInt(pos.x), false);
        writeMemory(posPtr + 4, 4, representFloatAsInt(pos.y), false);
        writeMemory(posPtr + 8, 4, representFloatAsInt(pos.z), false);
    end

Создание GUI

Для создания игрового интерфейса была использована библиотека mimgui - луа биндинги всем известного Dear ImGui.

В главном файле проекта была подключена библиотека mimgui и создан основной "фрейм", в котором далее будут отрисовываться все интерфейсы:

imgui.OnInitialize(function()
    imgui.GetIO().IniFilename = nil;
    uiComponents.logo.texture = imgui.CreateTextureFromFileInMemory(imgui.new('const char*', uiComponents.logo.base85), #uiComponents.logo.base85);

    Heroes.loadTextures();

    local style = imgui.GetStyle();
    style.WindowBorderSize = 5;
    style.WindowRounding = 10;
    style.FrameRounding = 5;

    local colors = style.Colors;
    colors[imgui.Col.Border] = imgui.ImVec4(0.57, 0.26, 0.11, 1);
    colors[imgui.Col.WindowBg] = imgui.ImVec4(0.35, 0.16, 0.06, 1);
    colors[imgui.Col.ChildBg] = imgui.ImVec4(0.95, 0.94, 0.89, 1);
end);

imgui.OnFrame(
    function() return Game.state ~= GameState.None end,
    function(thisWindow)
        thisWindow.HideCursor = true;

        table.foreach(Enemies.pool, function(k, v)
            uiComponents.healthBar(v, true);
        end);
        table.foreach(Heroes.pool, function(k, v)
            uiComponents.healthBar(v, false);
        end);
        
        local res = imgui.ImVec2(getScreenResolution());
        local style = imgui.GetStyle();
        if (Game.state == GameState.Menu) then
            uiComponents.mainMenu(res, style, uiComponents.logo.texture, { ---@diagnostic disable-line
                onExit = function()
                    Game.state = GameState.None;
                end,
                onPlay = function()
                    Game.start();
                end
            });
        elseif (Game.state == GameState.Playing) then
            uiComponents.gameInterface(
                res,
                style, ---@diagnostic disable-line
                Heroes.list,
                Game.money,
                function(heroIndex)
                    Utils.msg('clicked');
                    local hero = Heroes.list[heroIndex];
                    if (not hero) then
                        return Utils.msg('Error, invalid hero index!');
                    end
                    if (Game.money >= hero.price) then
                        Game.heroToPlace = hero;
                        Utils.msg('Place hero to any grid section. Click RMB to cancel.');
                    else
                        sampAddChatMessage('Dear Retard, you have not enough money to purchase this hero!', -1);
                    end
                end
            );
        end
    end
);

Интерфейсы я решил распихать по разным модулям, например так выглядел ui\game-interface.lua - модуль для отрисовки игрового интерфейса. Он возвращал функцию, которая в качестве параметров принимала: разрешение экрана, стиль ImGui, список героев, деньги игрока и коллбек-функцию, которая вызывалась при выборе персонажа.

local imgui = require('mimgui');

---@param res ImVec2
---@param style imgui.Style
---@param heroes any
return function(res, style, heroes, money, cb)
    local heroIconSize = imgui.ImVec2(75, 75);
    imgui.SetNextWindowPos(imgui.ImVec2(res.x / 2, 100), imgui.Cond.Always, imgui.ImVec2(0.5, 0));
    if (imgui.Begin('plants-vs-zombies-gui', nil, imgui.WindowFlags.NoDecoration + imgui.WindowFlags.AlwaysAutoResize)) then
        local fgdl = imgui.GetForegroundDrawList();
        local heroInfoSize = imgui.ImVec2(75, 100);

        imgui.PushStyleVarVec2(imgui.StyleVar.WindowPadding, imgui.ImVec2(0, 0));
        imgui.PushStyleColor(imgui.Col.ChildBg, style.Colors[imgui.Col.WindowBg]);
        if (imgui.BeginChild('money', heroInfoSize, true)) then
            local dl = imgui.GetWindowDrawList();
            local cursorPos = imgui.GetCursorScreenPos();
            fgdl:AddCircleFilled(cursorPos + imgui.ImVec2(heroInfoSize.x / 2, heroInfoSize.y / 2 - 15), 30, imgui.GetColorU32Vec4(style.Colors[imgui.Col.Border]), 50);
            fgdl:AddRectFilled(cursorPos + imgui.ImVec2(0, 75), cursorPos + imgui.ImVec2(heroInfoSize.x, 75 + 25), 0xFFffffff, 5);
            local moneySize = imgui.CalcTextSize(tostring(money));
            fgdl:AddText(cursorPos + imgui.ImVec2(heroInfoSize.x / 2 - moneySize.x / 2, 80), 0xFF000000, tostring(money));
        end
        imgui.EndChild();
        imgui.PopStyleColor();

        for index, hero in pairs(heroes) do
            imgui.SameLine();
            local pStart = imgui.GetCursorScreenPos();
            if (imgui.BeginChild('hero-' .. index, imgui.ImVec2(75, 100), true)) then
                local dl = imgui.GetWindowDrawList();
                local p = imgui.GetCursorScreenPos();
                dl:AddImage(hero.texture, p, p + imgui.ImVec2(75, 75));
                local color = imgui.GetColorU32Vec4(style.Colors[imgui.Col.ChildBg]);
                dl:AddRectFilledMultiColor(p + imgui.ImVec2(0, 60), p + imgui.ImVec2(75, 100), 0x00ffffff, 0x00ffffff, color, color);
                local nameSize = imgui.CalcTextSize(hero.name);
                dl:AddText(p + imgui.ImVec2(75 / 2 - nameSize.x / 2, 65), 0xFF000000, hero.name);
                local priceSize = imgui.CalcTextSize(tostring(hero.price));
                dl:AddText(p + imgui.ImVec2(75 / 2 - priceSize.x / 2, 80), 0xFF000000, tostring(hero.price));
            end
            imgui.EndChild();
            if (imgui.IsMouseClicked(0) and imgui.IsMouseHoveringRect(pStart, pStart + imgui.ImVec2(75, 100))) then
                cb(index);
            end
        end
        imgui.PopStyleVar();
    end
    imgui.EndChild();
end

Используемые картинки, например логотип для главного меню я "сжал" в base85 и поместил в resource.

Главное меню
Главное меню
Меню выбора растения
Меню выбора растения

"Растения"

Для создания «растений» (далее я буду называть их «героями» или «персонажами») я решил написать систему, подобную той, которую я писал при рефакторинге DOTG.

Для начала я создал отдельный модуль heroes.lua, в который в дальнейшем я помещу класс Hero. Данный модуль имеет такие функции как:

  • init() - загрузка всех героев

  • loadTextures() - загрузка текстур героев для дальнейшего использования в ImGui. Все текстуры были конвертированы в Base85 и вставлены в поле hero.imageBase85

  • process() - функция в которой будет прописана базовая логика для всех героев, например поиск цели для атаки, установка анимации атаки, вызов опциональных полей (например hero.onAttack())

Класс Heroes.Hero имеет следующие методы и поля:

---@class Hero
---@field name string
---@field maxHealth number
---@field handle number
---@field price number
---@field health? number
---@field attackAnimation? Animation
---@field attackInterval? number
---@field lastAttack? number
---@field noTargetRequired? boolean
---@field onTick? fun()
---@field onTargetFound? fun(self: Hero, target: Enemy)
---@field onDamageReceived? fun(self: Hero, damage: number, from: Enemy)
---@field onDeath? fun(self: Hero, damage: number, enemy: Enemy)
---@field findTarget fun(self: Hero, targets: {target: Enemy, distance: number}[])
---@field drawDebugInfo fun(self: Hero)
---@field destroy fun(self: Hero)
---@field die fun(self: Hero)
---@field dealDamage fun(self: Hero, damage: number, from: Enemy)
---@field storage? table<any, any>

Как вы могли заметить, многие поля являются опциональными, так как некоторые функции попросту не нужны некоторым персонажам, например метод onDamageReceived мне пригодился только при написании персонажа "орех", что бы он сбрасывал бронежилет если уровень его здоровья < 50% (в игре 2 модельки одного и того же персонажа, одна из которых в броне):

local ffi = require('ffi');
local hero = {
    ...
    attackInterval = math.huge
    ...
};

local CPed_SetModelIndex = ffi.cast('void(__thiscall *)(void*, unsigned int)', 0x5E4880);

function setCharModel(ped, model)
    assert(doesCharExist(ped), 'invalid ped');
    if (not hasModelLoaded(model)) then
        requestModel(model);
        loadAllModelsNow();
    end
    CPed_SetModelIndex(ffi.cast('void*', getCharPointer(ped)), ffi.cast('unsigned int', model));
end

function hero:onDamageReceived(damage, from)
    if (self.health <= self.maxHealth / 2) then
       setCharModel(self.handle, 269);
    end
end

return hero;

А это подсолнух, который вместо атаки врагов плюет игровыми монетами каждые 15 секунд.

...
local sunflower = {
    attackInterval = 15,
    ...
    damage = 0
};

function sunflower:onAttack(wasAnimationPlayed)
    local x, y, z = getCharCoordinates(self.handle);
    Object:new(1247, Vector3D(x, y, z + 2), nil, true, 1, 'sunflower');
end
...

Что бы каждый раз не проверять наличие опционального метода я написал простенькую функцию

function Heroes.Hero:call(fn, ...)
    return type(self[fn]) == 'function' and self[fn](self, ...) or nil;
end

Поиск целей для атаки

Пора бы уже приступить к написанию логики и основных механик. Начал я с написания системы поиска цели для "растений". Сделать это было не так сложно, так как игровое поле состоит из сетки, размер ячеек которой равен 5 на 5 метров. В экземпляре класса персонажа находится поле grid, которое содержит line - условная "строка" на игровом поле, и index - номер ячейки на игровом поле. Очевидно что цель должна находится перед персонажем, так что просто проходимся циклом "ячейкам", находящимся в поле зрения игрока, на строке, на которой находится наш персонаж.

Для начала пишем простенькую функцию для поиска всех NPC в ячейке,

function Map.findPedsInGrid(line, index)
    local peds = {};
    local pos = Map.getGridPos(line, index);
    for k, v in ipairs(getAllChars()) do
        local x, y, z = getCharCoordinates(v);
        if (x >= pos.x - 2.5 and x <= pos.x + 2.5 and y >= pos.y - 2.5 and y <= pos.y + 2.5) then
            table.insert(peds, v);
        end
    end
    return peds;
end

Теперь приступаем к написанию поиску цели. В самом начале функции "обнуляем" цель для персонажа (так как поиск цели происходит постоянно), затем создаем массив, который будет хранить хендлы (внутриигровые уникальные идентификаторы NPC). После получения списка всех NPC в ячейке, проверяем что NPC != "растению", затем сортируем массив для получения ближайшей цели.

function Heroes.isHero(entity)
    for k, v in ipairs(Heroes.pool) do
        if (v.handle == entity.handle) then
            return true;
        end
    end
end

function Heroes.Hero:updateTarget()
    local heroX, heroY, heroZ = getCharCoordinates(self.handle);
    self.target = nil;
    local enemies = {};
    for index = self.grid.index, 9 do
        local pos = Map.getGridPos(self.grid.line, index);

        if (DEV) then
            local x, y = convert3DCoordsToScreen(pos.x, pos.y, pos.z);
            renderDrawPolygon(x, y, 10, 10, 10, 10, 0xFFff00ff);
        end

        local peds = Map.findPedsInGrid(self.grid.line, index);
        if (#peds > 0) then
            for _, ped in ipairs(peds) do
                if (not Heroes.isHero(ped) and ped ~= self.handle and ped ~= PLAYER_PED) then
                    table.insert(enemies, {
                        handle = ped,
                        dist = getDistanceBetweenCoords3d(heroX, heroY, heroZ, getCharCoordinates(ped))
                    });
                end
            end
        end
    end
    pcall(table.sort, enemies, function(a, b)
        return a.dist < b.dist;
    end);
    local target = #enemies > 0 and enemies[1] or nil;
    if (target) then
        self.target = target.dist <= (self.attackDistance or 100) and target.handle or nil;
    end
    return enemies;
end

Теперь наше "растение" видит врагов.

Отрисовка здоровья

Так же я решил добавить индикатор здоровья над каждым персонажем и врагом. Для этого я написал модуль ui.health-bar, В дальнейшем мы будем вызывать эту функцию для каждого существа.

local imgui = require('mimgui');
local GUI_BAR_SIZE = imgui.ImVec2(50, 5);

---@type Hero | Enemy
return function(entity, isEnemy)
    local BGDL = imgui.GetBackgroundDrawList();
    local x, y, z = getCharCoordinates(entity.handle);
    local pos = imgui.ImVec2(convert3DCoordsToScreen(x, y, z + 1.5));
    BGDL:AddRectFilled(
        pos - imgui.ImVec2(GUI_BAR_SIZE.x / 2, 0),
        pos + imgui.ImVec2(GUI_BAR_SIZE.x / 2, GUI_BAR_SIZE.y),
        0xCC000000,
        2
    );
    local healthPercent = entity.health / entity.maxHealth;
    pos.x = pos.x - GUI_BAR_SIZE.x / 2;
    BGDL:AddRectFilled(
        pos + imgui.ImVec2(1, 1),
        pos + imgui.ImVec2(GUI_BAR_SIZE.x * healthPercent - 1, GUI_BAR_SIZE.y - 1),
        isEnemy and 0xFF4242db or 0xFF21b82e,
        2
    );
    BGDL:AddText(pos + imgui.ImVec2(GUI_BAR_SIZE.x + 5, -7), 0xFFFFFFFF, tostring(entity.health));
end

"Зомби"

Все взаимодействие с врагами я так же вынес в отдельный «класс». Я мог бы скопировать класс персонажей, однако я решил что в игре хватит всего двух видов врагов: обычные, имеющие 100 единиц здоровья и сильных, с 300 хп.

Для написания «мозгов» врагов я использовал немного иной подход, например, для поиска цели больше не идет поиск по ячейкам, вместо него я решил проводить линию от текущего положения врага до первой ячейки карты. Если была найдена точка соприкосновения, и этой точкой является NPC, я получал дистанцию от NPC до «зомби». Системы соблюдения интервала атаки, нанесения урона, включения анимации атаки и т. д. осталась подобна той, которую я использовал для логики растений.

function Enemies.Enemy:process(heroPool)
    -- print('Processing enemy', self.handle);
    if (not self.disableProcess) then
        local currentPos = Vector3D(getCharCoordinates(self.handle));
        
        local targetEndGrid = Map.getGridPos(self.line, 0);

        targetEndGrid.z = targetEndGrid.z + 1;
        local isPlantFound, colpoint = processLineOfSight(currentPos.x, currentPos.y, currentPos.z, targetEndGrid.x, targetEndGrid.y, targetEndGrid.z, false, false, true, false, false, false, false, false);
        local distanceToTarget = colpoint == nil and -1 or getDistanceBetweenCoords3d(currentPos.x, currentPos.y, currentPos.z, colpoint.pos[1], colpoint.pos[2], colpoint.pos[3]);

        if (not isPlantFound or distanceToTarget > 1.5) then
            -- taskCharSlideToCoord(self.handle, 0, 0, 0, 0, 1);
            if (os.clock() - self.lastXUpdate > ENEMY_X_UPDATE_SPEED) then
                self:setCoordinates(Vector3D(currentPos.x - (DEV and 0.01 or 0.01), currentPos.y, currentPos.z));
                self.lastXUpdate = os.clock();
            end
        else
            if (colpoint.entityType == 3) then
                local targetHandle = getCharPointerHandle(colpoint.entity);
                if (not doesCharExist(targetHandle)) then
                    return print('ERROR: cannot get target handle for enemy!');
                end
                
                -- Deal damage to target
                local timeSinceLastAttack = os.clock() - self.lastAttack;
                if (timeSinceLastAttack > self.attackInterval) then
                    for _, v in pairs(heroPool) do
                        if (v.handle == targetHandle) then
                            v:dealDamage(self.damage, self);
                            v:call('onDamageReceived', self.damage, self);
                            if (self.attackAnimation and hasAnimationLoaded(self.attackAnimation.file)) then
                                clearCharTasksImmediately(self.handle);
                                taskPlayAnim(self.handle, self.attackAnimation.name, self.attackAnimation.file, 4.0, false, true, true, true, 0);
                            else
                                print('WARNING: Missing animation for enemy!');
                            end
                            self.lastAttack = os.clock();
                            break;
                        end
                    end
                end
            end
        end

        if (DEV) then
            local x, y = convert3DCoordsToScreen(currentPos.x, currentPos.y, currentPos.z);
            local x2, y2 = convert3DCoordsToScreen(targetEndGrid.x, targetEndGrid.y, targetEndGrid.z);
            renderDrawLine(x, y, x2, y2, 1, isPlantFound and 0xFFffff00 or 0xFF00ffff);
            renderFontDrawText(font, ('HP: %s\nTarget: %s\nDist: %0.2f\nDist to end: %0.2f\nX: %s'):format(tostring(self.health), tostring(isPlantFound), distanceToTarget, self:getDistanceToFinish(), self.x), x, y, 0xFFffffff, false);
        end
    end
end
Теперь и враги видят нас
Теперь и враги видят нас

Далее я нашел не очень приятный баг: после того как «зомби» убил «растение», «зомби» телепортировался вперед, на то расстояние, где он был бы если бы не нашел цель. Данный баг возник из‑за того что координаты врага зависят от времени, и плавно переходят от точки спавна до начала карты. Функция, использованная расчета координат выглядит так, позднее я избавлюсь от нее:

function Utils.bringVec3To(from, to, start_time, duration)
    local timer = os.clock() - start_time
    if timer >= 0.00 and timer <= duration then
        local count = timer / (duration / 100)
        return Vector3D(
            from.x + (count * (to.x - from.x) / 100),
            from.y + (count * (to.y - from.y) / 100),
            from.z + (count * (to.z - from.z) / 100)
        ), true
    end
    return (timer > duration) and to or from, false
end

Что бы пофиксить данный баг мне пришлось создать поля x и lastXUpdate. В x хранилось положение по оси X, а в lastXUpdate время последнего обновления положения. Далее в Enemy:process() был добавлен следующий код:

...
if (not isPlantFound or distanceToTarget > 1.5) then
    if (os.clock() - self.lastXUpdate > ENEMY_X_UPDATE_SPEED) then
        self:setCoordinates(Vector3D(currentPos.x - 0.01, currentPos.y, currentPos.z));
        self.lastXUpdate = os.clock();
    end
else
...
Драка на поляне
Драка на поляне

Спавн зомби

Дебаг информация отображается только у последнего
Дебаг информация отображается только у последнего

Сразу после добавления системы я столкнулся с очередным багом: почему-то, логика работала только у последнего созданного врага. Данный баг возникал из-за того что при спавне нового врага хендлы всех остальных менялись на хендл только что созданного. Происходило это из-за того что грубо говоря я копировал не саму таблицу, а указатель на нее. Ошибку я исправил обернувEnemyData[type] в Utils.copyTable() .

"Обход" серверного античита

Игра не имеет какого-либо читерского функционала, однако, карта игры построена в небе, куда и телепортируется игрок при старте. Дабы избежать кика от античита был написан модуль который позволял блокировать все входящие RPC и пакеты. Таким образом после старта игры для других игроков вы остаетесь на старом месте и находитесь в AFK. Так же перед телепортацией на игровую карту координаты игрока сохраняются, а при выходе из мини-игры и перед отключением блокировки персонаж телепортируется на старые координаты.

local RakNet = {
    nop = false
};

function RakNet.init()
    for _, event in ipairs({ 'onSendPacket', 'onReceivePacket', 'onSendRpc', 'onReceiveRpc' }) do
        addEventHandler(event, function()
            return RakNet.nop;
        end);
    end
end

return RakNet;

Газонокосилки

Изначально я не планировал делать отдельный класс для газонокосилок, но позже стало понятно что с классами будет меньше гемора.

Класс Vehicle состоит всего из нескольких полей:

  • handle - уникальный игровой идентификатор

  • movementStartTime - начало движения

  • spawnPos - точка спавна

  • endX - точка, на которой газонокосилка будет полностью удаляться

Движение я сделал с помощью той функции, которая не подошла под движение врага. Тут она оказалась как нельзя кстати, ведь с прошлым багом мы точно не столкнемся, так как газонокосилка никогда не остановится. Изначально для движения я хотел использовать встроенные функции GTA:SA, такие как carGotoCoordinates(Vehicle car, float driveToX, float driveToY, float driveToZ) или setCarForwardSpeed(Vehicle car, float speed) , но они мне не подошли. Первая функция заставляет транспорт ехать к определенной точке и объезжать все препятствия, которыми как раз и считаются NPC, так что после начала движения газонокосилка просто объезжала врагов. Вторая функция не подошла из-за того что газонокосилка сбивалась с маршрута из-за столкновения с NPC, а при выключении коллизии не сработала бы проверка на касание, и, как следствие, NPC бы перестали умирать. Конечная реализация выглядит примерно так:

function Vehicles.process(enemyPool, heroPool)
    for index, vehicle in ipairs(Vehicles.pool) do
        vehicle:process(enemyPool, heroPool);
    end
end

function Vehicles.Vehicle:process(enemyPool, heroPool)
    print(#enemyPool)
    if (self.movementStartTime) then
        local newX = Utils.bringFloatTo(self.startX, self.endX, self.movementStartTime, 5);
        setCarCoordinates(self.handle, newX, self.spawnPos.y, self.spawnPos.z);
        if (newX >= self.endX) then
            self.movementStartTime = nil;
            self:destroy();
            return;
        end
    end
    
    local x, y, z = getCarCoordinates(self.handle);
    local sx, sy = convert3DCoordsToScreen(x, y, z);
    for _, entity in ipairs(Utils.mergeTable(enemyPool, heroPool)) do
        if (DEV) then
            local ex, ey = convert3DCoordsToScreen(getCharCoordinates(entity.handle));
            renderDrawLine(sx, sy, ex, ey, 1, 0xFF0000ff);
        end
        if (getDistanceBetweenCoords3d(x, y, z, getCharCoordinates(entity.handle)) <= 1) then
            Utils.msg('ped tounching veh, ped', entity.handle, 'veh', self.handle);
            if (not self.movementStartTime) then
                self.movementStartTime = os.clock();
            end
            entity:kill();
        end
    end

    if (DEV) then
        renderFontDrawText(font, ('Handle: %d\nX: %0.2f'):format(self.handle, x), sx, sy, 0xFFffffff, false);
    end
end

Сборка проекта

Для удобства я создал переменную DEV, ее значение зависело от того, собран ли проект (бандлер автоматически создает переменную LUBU_BUNDLED).

DEV = LUBU_BUNDLED == nil; ---@diagnostic disable-line
BASE_PATH = DEV and 'X:\\Games\\GTASA\\moonly\\pvz\\src\\' or getWorkingDirectory();

Подключаем все необходимые модули и библиотеки.

require('moonloader');
local imgui = require('mimgui');
local Vector3D = require('vector3d');
local Map = require('map');
local Camera = require('camera');
local Utils = require('utils');
local Heroes = require('heroes');
local Enemies = require('enemy');
local uiComponents = {
    mainMenu = require('ui.main-menu'),
    gameInterface = require('ui.game-interface')
};

Создаем таблицу с данными об игроке

local Game = {
    saved = {
        heading = 0,
        pos = Vector3D(0, 0, 0)
    },
    state = GameState.Menu,
    money = 9999,
    heroToPlace = nil
};

function Game.destroy()
    Heroes.destroy();
    Enemies.destroy();
    Map.destroy();
    Game.state = GameState.Menu;
    setCharCoordinates(PLAYER_PED, Game.saved.pos.x, Game.saved.pos.y, Game.saved.pos.z);
    Camera.restore();
    RakNet.nop = false;
end

function Game.start()
    Game.saved = {
        heading = getCharHeading(PLAYER_PED),
        pos = Vector3D(getCharCoordinates(PLAYER_PED))
    };
        
    Map.init();
    Enemies.init();
    Camera.init(Vector3D(Map.pos.x + 17, Map.pos.y - 1, Map.pos.z + 20), Vector3D(Map.pos.x + 17, Map.pos.y + 11, Map.pos.z));
    Camera.update();
    setCharCoordinates(PLAYER_PED, Map.pedPos.x, Map.pedPos.y, Map.pedPos.z);
    setCharHeading(PLAYER_PED, Map.pedHeading);
    Game.state = GameState.Playing;
    RakNet.nop = true;
end

Далее регистрируем команду открывающую игровое меню в чате:

    sampRegisterChatCommand('pvz', function()
        if (Game.state == GameState.Playing) then
            Game.state = GameState.Menu;
            Game.destroy();
        elseif (Game.state == GameState.Menu) then
            Game.state = GameState.None;
        elseif (Game.state == GameState.None) then
            Game.state = GameState.Menu;
        end
        Utils.msg('Game state was changed to:', Game.state);
    end);

Затем переходим в бесконечный цикл, там мы будем:

  • вызывать поля process у всех растений и врагов

  • обрабатывать создание растения

  • рисовать обводку у ячейки на которую наведен курсор игрока

while (true) do
        wait(0);
        if (Game.state == GameState.Playing) then
            -- Draw hovered grid outline
            if (Game.heroToPlace) then
                local line, index, pos = Map.getGridForCoord(Map.getPointerPos(nil));
                if (line ~= -1 and pos) then
                    local x1, y1 = convert3DCoordsToScreen(pos.x - 2.5, pos.y - 2.5, pos.z);
                    local x2, y2 = convert3DCoordsToScreen(pos.x + 2.5, pos.y + 2.5, pos.z);
                    local x3, y3 = convert3DCoordsToScreen(pos.x - 2.5, pos.y + 2.5, pos.z);
                    local x4, y4 = convert3DCoordsToScreen(pos.x + 2.5, pos.y - 2.5, pos.z);
                    renderDrawLine(x1, y1, x3, y3, 2, 0xFFffffff);
                    renderDrawLine(x1, y1, x4, y4, 2, 0xFFffffff);
                    renderDrawLine(x2, y2, x4, y4, 2, 0xFFffffff);
                    renderDrawLine(x3, y3, x2, y2, 2, 0xFFffffff);
                end
                if (wasKeyPressed(VK_LBUTTON)) then
                    sampAddChatMessage(('Placed hero with type "%s" to (%d:%d)'):format(Game.heroToPlace, line, index), 0xFF00ff00);
                    Heroes.Hero:new(Game.heroToPlace, line, index)
                    Game.money = Game.money - Game.heroToPlace.price;
                    Game.heroToPlace = nil;
                elseif (wasKeyPressed(VK_RBUTTON)) then
                    Game.heroToPlace = nil;
                end
            end

            -- Processing
            Heroes.process(Enemies.pool);
            Enemies.process(Enemies.pool, Heroes.pool);
            Map.process(Enemies.pool, {
                onSunTaked = function()
                    Game.money = Game.money + 50;
                    printStringNow('~y~+50', 1250);
                end,
                spawnEnemy = function(type)
                    math.randomseed(os.time() * math.random(1, 10));
                    local type = math.random(1, 1);
                    math.randomseed(os.time() * math.random(1, 10));
                    local line = math.random(1, 5);
                    Utils.msg('Spawning enemy with type', type, 'on line', line);
                    Enemies.Enemy:new(type, line);
                    Map.lastEnemySpawned = os.clock();
                end
            });
        end
    end

Результат

Сборка проекта

Для сборки проекта в 1 скрипт я использовал собственный бандлер - LuBu (GitHub). Данный бандлер я написал для личных нужд и для практики в Go. Сборка выполняется одной простенькой командой - ./lubu.exe bundle-config.json

Заключение

При написании данных проектов я получил довольно смешанные чувства, вроде бы делаешь что-то прикольное, но в то же время понимаешь что это просто юзлесс проекты которые не принесут никакой пользы.

P.S Предчувствую гневные комментарии на счет кодстайла, который не соответствует общепринятому стандарту Lua. CamelCase был использован для «эстетического» удовольствия, так как MoonLoader API использует именно его, и, было бы странно миксовать snake_case и camelCase, а семиколоны и скобки в условиях вошли в привычку после TypeScript'а.

Комментарии (0)