Содержание
Немного о GTA:SA и SA:MP
Все вы знаете такую игру как Grand Theft Auto San Andreas, однако многие из вас даже не догадывались о том что в нее можно играть с другими игроками, специально для этого в 2006 разработчик "Kalcor" выпустил модификацию San Andreas MultiPlayer (SA:MP). С момента выхода мультиплеера прошло уже около 20 лет, однако на серверах и по сей день играет огромное количество игроков. Среди самых крупных проектов можно отметить Arizona RP - ~30.000 игроков каждый день и BlackRussia - средний онлайн более 90.000 игроков.
С каждым годом SA:MP проекты становятся все лучше и лучше, например если раньше со стандартного лаунчера вы могли зайти на любой сервер, то сейча�� многие проекты пишут собственные лаунчеры с эксклюзивными фишками, например голосовым чатом, встроенными модификациями и CEF интерфейсами.
Для написания модификаций для одиночной игры и мультиплеера можно было использовать Cleo, однако в нем не было функций для взаимодействия с мультиплеером. Для взаимодействия с мультиплеером в далеком 2013 году разработчиками из России был разработан плагин SAMPFUNCS. Позже, в 2016 году, тем же разработчиком был написан загрузчик .lua скриптов - MoonLoader. Благодаря простоте Lua MoonLoader позволил игрокам с нулевыми знаниями программирования писать свои собственные скрипты для улучшения игры.
Разработчик мультиплеера и его поехавшая крыша
Актуальная версия мультиплеера - 0.3.7, разработка версии 0.3.8 прекратилась в 2018 году, аргументировал это отсутствием интереса у разработчиков большинства серверов к главной особенности 0.3.8 — возможности подгружать собственные модели, однако сообщество сомневается в том что он действительно планировал выпустить данное обновление. Все дело в том что сам мультиплеер имеет огромнейшее количество дыр, таких как RCE, которые разработчик не спешил исправлять.
В 2018 году разработчики из России начали создавать свой аналог сампу - Render Ware Multiplayer, однако уже спустя год разработка прекратилась, так как Kalcor начал угрожать судом. Ознакомится с полной историей можете тут.
В 2020 году Kalcor, не смотря на большой заработок с мультипеера решил его закрыть и начал "перекрывать" кислород разработчикам популярных проектов. Так, в 2020 году он удалил официальный сайт и форум мультиплеера, а так же заблокировал Российские хостинги, из-за чего сервера не отображались в лаунчере. На данный момент ходят слухи что Kalcor является ведущим разработчиком в NASA.
Ну и парочка сообщений от разработчика, оставленных на ныне закрытом официальном форуме:
«Больше никаких ссылок на YouTube на это форуме, ок? YouTube захвачен феминистками и SJW (борцами за справедливость), которые не особо дружелюбны к геймерам.Ссылки на YouTube будут работать еще какое‑то время, но в итоге будут заблокированы на нашем форуме. Twitch или Steam наилучшие альтернативные сервисы на данный момент.»
«Дело в том, что я прав. И если вы думаете, что я ошибаюсь — вы ошибаетесь»
"Модульность" проектов и сообщество разработчиков 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; enddestroy()- полное удаление персонажаfunction Enemies.Enemy:destroy() if (doesCharExist(self.handle)) then deleteChar(self.handle); print('Enemy was destroyed, handle = ', self.handle); end endprocess()- обработка логики действий персонажа (ходьба, поиск ближайшей цели, установка анимаций и т.д.)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 enddeath()- скрытие персонажа за пределы экрана игрока для дальнейшего удаленияfunction Enemies.Enemy:death() setCharCoordinates(self.handle, 0, 0, -100); ... endsetCoordinates()- изменение координат персонажа, использовалось для имитации движения. Данный метод пришлось написать так как разработчик 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.imageBase85process()- функция в которой будет прописана базовая логика для всех героев, например поиск цели для атаки, установка анимации атаки, вызов опциональных полей (например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'а.
