«Модульность» проектов и сообщество разработчиков 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 .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
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
Как вы могли заметить, многие поля являются опциональными, так как некоторые функции попросту не нужны некоторым персонажам, например метод 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'а.