script_name('HP Bar Overlay') script_author('marcumypka + нейронка') script_version('1.1') require 'lib.moonloader' local encoding = require 'encoding' encoding.default = 'CP1251' -- поменяй, если у тебя другая кодировка игры local u8 = encoding.UTF8 local function T(s) return u8:decode(s) end local sampev = require 'lib.samp.events' ---------------------------------------------------------------- -- CONFIG ---------------------------------------------------------------- local cfgDir = getWorkingDirectory() .. '\\config\\' local cfgFile = cfgDir .. 'hp_bar_config.txt' local CONFIG = { textdraw_id = 2048, -- стартовая подсказка (реально ID каждый раз новый — см. авто-привязку по позиции ниже) textdraw_kind = 'global', max_hp = 100, -- при этом ХП полоска полностью заполнена (100%) hp_cap = 160, -- реальный потолок ХП (например, +60 от наркотиков) — полоска не растёт выше 100%, но цифра покажет честное значение bar_x = 1741.0, -- твои подобранные координаты bar_y = 105.0, bar_w = 80.0, bar_h = 16.0, hide_original = true, show_number = true, bar_color = 0xFFD3141A, -- сплошной красный (ARGB), как на референсе. Поставь nil, чтобы включить градиент по проценту ХП. bg_color = 0xFF101010, -- цвет рамки и пустой (незалитой) части бара border_width = 3, -- толщина тёмной рамки, как на референсе ref_x = nil, -- запомненная позиция textdraw'а ХП (для авто-поиска между сессиями); заполнится после первого /hpid ref_y = nil, known_ids = { [2048] = true, [2208] = true }, -- известные варианты ID — сервер каждый раз может выдать любой из них; если появится текст-число, привяжемся сразу, без /hpid allowed_host = '85.234.65.36', -- скрипт работает только на этом сервере allowed_port = 7777, } local function ensureDir() if not doesDirectoryExist(cfgDir) then createDirectory(cfgDir) end end local function saveConfig() ensureDir() local f = io.open(cfgFile, 'w') if not f then return end local knownList = {} for id in pairs(CONFIG.known_ids or {}) do table.insert(knownList, tostring(id)) end f:write(('%s|%s|%s|%s|%s|%s|%s|%s|%s\n'):format( tostring(CONFIG.textdraw_id or ''), CONFIG.textdraw_kind, tostring(CONFIG.bar_x), tostring(CONFIG.bar_y), tostring(CONFIG.bar_w), tostring(CONFIG.bar_h), tostring(CONFIG.ref_x or ''), tostring(CONFIG.ref_y or ''), table.concat(knownList, ',') )) f:close() end local function loadConfig() ensureDir() local f = io.open(cfgFile, 'r') if not f then return end local line = f:read('*l') f:close() if not line then return end local id, kind, x, y, w, h, rx, ry, known = line:match('^(.-)|(.-)|(.-)|(.-)|(.-)|(.-)|(.-)|(.-)|(.*)$') if id and id ~= '' then CONFIG.textdraw_id = tonumber(id) end if kind and kind ~= '' then CONFIG.textdraw_kind = kind end if x and tonumber(x) then CONFIG.bar_x = tonumber(x) end if y and tonumber(y) then CONFIG.bar_y = tonumber(y) end if w and tonumber(w) then CONFIG.bar_w = tonumber(w) end if h and tonumber(h) then CONFIG.bar_h = tonumber(h) end if rx and tonumber(rx) then CONFIG.ref_x = tonumber(rx) end if ry and tonumber(ry) then CONFIG.ref_y = tonumber(ry) end if known and known ~= '' then for numStr in known:gmatch('[^,]+') do local n = tonumber(numStr) if n then CONFIG.known_ids[n] = true end end end end local hpFont = renderCreateFont('Arial', 10, 5) ---------------------------------------------------------------- -- Проверка сервера: скрипт должен работать только на CONFIG.allowed_host:allowed_port. -- Игра хранит адрес подключения уже как числовой IP, а не как введённый домен, -- поэтому резолвим allowed_host через LuaSocket и сравниваем IP с IP. ---------------------------------------------------------------- local function getCurrentServerIpPort() local ok, a, b = pcall(sampGetCurrentServerAddress) if not ok or a == nil then return nil, nil end if type(a) == 'string' and b == nil and a:find(':') then local ip, port = a:match('^(.-):(%d+)$') return ip, tonumber(port) end return a, tonumber(b) end local function isAllowedServer() local ip, port = getCurrentServerIpPort() if not ip then return false end if CONFIG.allowed_port and port and tonumber(port) ~= CONFIG.allowed_port then return false end if ip == CONFIG.allowed_host then return true end -- на случай, если либа всё же хранит хост как есть local okReq, socket = pcall(require, 'socket') if okReq and socket and socket.dns then local okResolve, resolvedIp = pcall(function() return socket.dns.toip(CONFIG.allowed_host) end) if okResolve and resolvedIp and resolvedIp == ip then return true end end return false end ---------------------------------------------------------------- -- Состояние ---------------------------------------------------------------- local currentHP = CONFIG.max_hp local testMode = false local testDir = -1 local logMode = false local autoLocked = false -- нашли ли уже нужный textdraw в ЭТОЙ сессии (автоматически или вручную) local serverAllowed = false -- true, только если мы подключены к CONFIG.allowed_host:allowed_port local trackedVisible = false -- true, только пока на привязанном id/типе реально лежит число (ХП); если сервер -- переиспользовал этот id под другой textdraw (не число) — прячем свой бар local otherVisible = {} -- множество id ЛЮБЫХ других global textdraw'ов, которые сейчас показаны на экране; -- пока оно не пустое — наш бар скрыт (запрошено: прятаться при абсолютно любом чужом textdraw'е) -- Кэш последних увиденных строк и позиций по каждому id/типу — заполняется -- событиями, не опросом. local cache = { player = {}, global = {} } local posCache = { player = {}, global = {} } -- Для эвристики "по известным ID": запоминаем последнее увиденное числовое -- значение по каждому кандидату, чтобы привязываться только когда число -- РЕАЛЬНО меняется (получен урон/лечение), а не на первом же попавшемся -- числе — иначе можно словить случайный текстдроу, который просто -- совпал по ID и один раз показывает похожее число. local candidateValues = { player = {}, global = {} } local function cleanText(text) if not text then return nil end return text:gsub('~%a~', ''):gsub('%s+', '') end -- textdraw.position из событий — обычно таблица {x=.., y=..}; на всякий -- случай пробуем и вариант с числовыми индексами [1]/[2]. local function posOf(textdraw) if not textdraw or not textdraw.position then return nil end local p = textdraw.position local x = p.x or p[1] local y = p.y or p[2] if not x or not y then return nil end return x, y end local function bindTo(kind, id) CONFIG.textdraw_id = id CONFIG.textdraw_kind = kind autoLocked = true trackedVisible = true if kind == 'global' then CONFIG.known_ids[id] = true -- запоминаем этот ID на будущее, вдруг сервер снова его выдаст end local px, py = posCache[kind][id] and posCache[kind][id].x, posCache[kind][id] and posCache[kind][id].y if px and py then CONFIG.ref_x, CONFIG.ref_y = px, py end saveConfig() end local function noteSeen(kind, id, text, x, y) if not serverAllowed then return end if id == nil or text == nil then return end cache[kind][id] = text if x and y then posCache[kind][id] = { x = x, y = y } end local clean = cleanText(text) if logMode and CONFIG.known_ids and CONFIG.known_ids[id] then local posStr = (x and y) and (' pos=' .. tostring(x) .. ',' .. tostring(y)) or '' sampAddChatMessage(('{808080}[%s] id=%s -> "%s"%s'):format(kind:upper(), tostring(id), text, posStr), -1) end -- Автопривязка №1: если id входит в список "известных" (CONFIG.known_ids) -- и на нём чистое число — запоминаем значение, но привязываемся только -- когда это число ИЗМЕНИЛОСЬ между двумя замерами (реальный ХП меняется -- при уроне/лечении; случайный текстдроу с похожим ID обычно стоит на -- месте, и мы больше не хотим цепляться к нему на первом же совпадении). -- Работает и ПОСЛЕ первой привязки, и вообще в любой момент игры: сервер -- может динамически менять, какой именно id (2048 или 2208) сейчас несёт -- ХП, туда-сюда, не только при открытии меню. Поэтому правило простое: -- "кто из известных id последним реально обновился валидным числом ХП — -- тот и главный", независимо от того, что было привязано раньше. if CONFIG.known_ids and CONFIG.known_ids[id] and clean and clean:match('^%d+$') then local num = tonumber(clean) candidateValues[kind] = candidateValues[kind] or {} local prev = candidateValues[kind][id] local inHpRange = num >= 0 and num <= (CONFIG.hp_cap or CONFIG.max_hp) local isCurrentlyTracked = autoLocked and kind == CONFIG.textdraw_kind and id == CONFIG.textdraw_id if prev == nil then candidateValues[kind][id] = num elseif prev ~= num then candidateValues[kind][id] = num if inHpRange and not isCurrentlyTracked then bindTo(kind, id) sampAddChatMessage(T('{7FFF00}[HPBar] Переключился на textdraw ХП: id=') .. tostring(id) .. T(' (сервер сменил, кто из известных ID несёт ХП).'), -1) end -- если число изменилось, но вышло за пределы 0..max_hp (например, патроны -- или деньги) — не переключаемся, это не ХП. end end -- Автопривязка №2: если id неизвестен заранее, но знаем сохранённую с -- прошлого раза позицию (CONFIG.ref_x/ref_y) — сравниваем по ней. if not autoLocked and CONFIG.ref_x and x and y then local dx, dy = math.abs(x - CONFIG.ref_x), math.abs(y - CONFIG.ref_y) if dx < 2 and dy < 2 and clean and clean:match('^%d+$') then bindTo(kind, id) sampAddChatMessage(T('{7FFF00}[HPBar] Автоматически нашёл textdraw ХП: id=') .. tostring(id) .. T(' (по позиции).'), -1) end end -- если это отслеживаемый нами textdraw — сразу обновляем ХП. -- ВАЖНО: используем ID только после того, как он подтверждён в ЭТОЙ -- сессии (через bindTo — автоматически по позиции или вручную /hpid). -- Иначе есть риск словить чужой textdraw, которому сервер в этот раз -- случайно выдал тот же ID, что был у ХП в прошлый раз. if autoLocked and CONFIG.textdraw_id and id == CONFIG.textdraw_id and kind == CONFIG.textdraw_kind then local digits = clean and clean:match('%d+') if digits then currentHP = tonumber(digits) trackedVisible = true if CONFIG.hide_original then pcall(sampTextdrawSetString, id, ' ') end else -- на нашем id сейчас не число — скорее всего сервер временно занял -- этот textdraw под что-то другое (меню, подсказка и т.п.). Прячем -- свой бар, пока сюда не вернётся число ХП. trackedVisible = false end end end ---------------------------------------------------------------- -- Реальные имена событий взяты из твоего lib/samp/events.lua -- (RPC.SHOWTEXTDRAW и RPC.TEXTDRAWSETSTRING). PlayerTextDraw-событий -- в этой версии либы нет вообще — если нужный textdraw окажется именно -- player-типа, эти хуки его не поймают, тогда придётся искать другой путь. ---------------------------------------------------------------- -- true, если этот id/kind — НЕ наш привязанный ХП-textdraw (т.е. "чужой"). local function isOtherTextdraw(kind, id) return not (autoLocked and kind == CONFIG.textdraw_kind and id == CONFIG.textdraw_id) end -- Проверяет напрямую через SAMP-функции, всё ещё ли textdraw реально на -- экране. Нужно как страховка на случай, если onHideTextDraw в этой версии -- либы не существует/не вызывается — тогда события мы не дождёмся, но опрос -- всё равно заметит, что textdraw пропал. local function isTextdrawStillShown(id) local okExists, exists = pcall(sampTextdrawIsExists, id) if okExists and exists == false then return false end local okVisible, visible = pcall(sampTextdrawIsVisible, id) if okVisible and visible == false then return false end return true end function sampev.onShowTextDraw(textdrawId, textdraw) local x, y = posOf(textdraw) noteSeen('global', textdrawId, textdraw and textdraw.text, x, y) if isOtherTextdraw('global', textdrawId) then otherVisible[textdrawId] = true end end function sampev.onTextDrawSetString(id, text) noteSeen('global', id, text) end -- Если у твоей версии lib/samp/events есть событие скрытия textdraw'а — этот -- хук спрячет наш бар, когда сервер явно скрывает привязанный id (а не просто -- меняет на нём текст). Если события такого нет в либе, хук просто никогда -- не вызовется — ошибки не будет. function sampev.onHideTextDraw(id) otherVisible[id] = nil if autoLocked and CONFIG.textdraw_kind == 'global' and id == CONFIG.textdraw_id then trackedVisible = false end end ---------------------------------------------------------------- -- Цвет полоски. По умолчанию сплошной красный (как в оригинальном HUD). -- Если захочешь вернуть градиент зелёный->жёлтый->красный по проценту ХП — -- смени CONFIG.bar_color на nil, тогда заработает colorForPercent ниже. ---------------------------------------------------------------- local function colorForPercent(p) local r, g, b if p > 0.5 then local t = (p - 0.5) * 2 r = math.floor(255 * (1 - t)) g = 255 b = 0 else local t = p * 2 r = 255 g = math.floor(255 * t) b = 0 end return 0xFF000000 + (r * 0x10000) + (g * 0x100) + b end local function drawBar() local hp = currentHP local hardCap = CONFIG.hp_cap or CONFIG.max_hp if hp < 0 then hp = 0 end if hp > hardCap then hp = hardCap end local percent = CONFIG.max_hp > 0 and (hp / CONFIG.max_hp) or 0 if percent > 1 then percent = 1 end -- бонусные ХП сверху 100 не переполняют полоску, она просто остаётся полной local x, y, w, h = CONFIG.bar_x, CONFIG.bar_y, CONFIG.bar_w, CONFIG.bar_h local bg = CONFIG.bg_color or 0xFF101010 local bw = CONFIG.border_width or 3 -- тёмная плашка на весь бар (и рамка, и "пустая" часть — они одного цвета, -- как на референсе, никакого просвечивания фона игры) pcall(renderDrawBox, x, y, w, h, bg) -- красная заливка слева, отступ на толщину рамки со всех сторон if percent > 0 then local fillColor = CONFIG.bar_color or colorForPercent(percent) local innerW = (w - bw * 2) * percent pcall(renderDrawBox, x + bw, y + bw, innerW, h - bw * 2, fillColor) end if CONFIG.show_number then local text = tostring(math.floor(hp)) local tx, ty = x + w + bw + 6, y + h / 2 - 6 -- псевдо-обводка: чёрная копия текста со сдвигом во все стороны, сверху — белый текст for _, off in ipairs({ {-1,-1},{0,-1},{1,-1}, {-1,0},{1,0}, {-1,1},{0,1},{1,1} }) do pcall(renderFontDrawText, hpFont, text, tx + off[1], ty + off[2], 0xFF000000) end pcall(renderFontDrawText, hpFont, text, tx, ty, 0xFFFFFFFF) end end ---------------------------------------------------------------- -- Команды ---------------------------------------------------------------- sampRegisterChatCommand('hplog', function() logMode = not logMode if logMode then sampAddChatMessage(T('{FFFF00}[HPBar] Лог включён. Возьми урон/полечись в игре и смотри, у какого id меняется цифра — это и есть ХП. Формат: [PLAYER]/[GLOBAL] id=... -> "текст"'), -1) else sampAddChatMessage(T('{7FFF00}[HPBar] Лог выключен.'), -1) end end) sampRegisterChatCommand('hpid', function(param) local id, kind = param:match('^(%d+)%s*(%a*)$') id = tonumber(id) if not id then sampAddChatMessage(T('{FF6347}[HPBar] Используй: /hpid [player|global] (kind по умолчанию player)'), -1) return end kind = (kind == 'global') and 'global' or 'player' bindTo(kind, id) sampAddChatMessage(T('{7FFF00}[HPBar] Привязано: id=') .. tostring(id) .. T(' kind=') .. kind .. (CONFIG.ref_x and T(' (позиция запомнена, дальше найдётся сам)') or T(' (позиция ещё не известна — если после перезахода не найдётся сам, привяжи через /hpid ещё раз)')), -1) -- если это значение уже когда-то мелькало в кэше — подхватим сразу local cachedText = cache[kind][id] if cachedText then noteSeen(kind, id, cachedText) end end) sampRegisterChatCommand('hppos', function(param) local x, y, w, h = param:match('^(%S+)%s+(%S+)%s+(%S+)%s+(%S+)$') if not x then sampAddChatMessage(T('{FF6347}[HPBar] Используй: /hppos x y ширина высота (сейчас: ') .. ('%.1f %.1f %.1f %.1f'):format(CONFIG.bar_x, CONFIG.bar_y, CONFIG.bar_w, CONFIG.bar_h) .. T(')'), -1) return end CONFIG.bar_x, CONFIG.bar_y, CONFIG.bar_w, CONFIG.bar_h = tonumber(x), tonumber(y), tonumber(w), tonumber(h) saveConfig() sampAddChatMessage(T('{7FFF00}[HPBar] Позиция обновлена.'), -1) end) sampRegisterChatCommand('hpbar', function(param) if param == 'test' then testMode = not testMode sampAddChatMessage(testMode and T('{7FFF00}[HPBar] Тест включён.') or T('{7FFF00}[HPBar] Тест выключен.'), -1) elseif param == 'hide' then CONFIG.hide_original = not CONFIG.hide_original sampAddChatMessage(CONFIG.hide_original and T('{7FFF00}[HPBar] Прячу оригинал.') or T('{7FFF00}[HPBar] Оригинал не прячу.'), -1) else sampAddChatMessage(T('{FFFF00}[HPBar] Команды: /hplog, /hpid [player|global], /hppos x y w h, /hpbar test, /hpbar hide'), -1) end end) ---------------------------------------------------------------- -- Главный цикл ---------------------------------------------------------------- function main() while not isSampAvailable() do wait(100) end loadConfig() serverAllowed = isAllowedServer() if not serverAllowed then sampAddChatMessage(T('{808080}[HPBar] Скрипт настроен только под ') .. CONFIG.allowed_host .. ':' .. tostring(CONFIG.allowed_port) .. T(', на этом сервере он не активен.'), -1) return end logMode = false -- лог по умолчанию выключен, включай вручную через /hplog lua_thread.create(function() while true do wait(50) if testMode then currentHP = currentHP + testDir * 2 if currentHP <= 0 then currentHP = 0; testDir = 1 end if currentHP >= CONFIG.max_hp then currentHP = CONFIG.max_hp; testDir = -1 end end end end) -- Страховочный опрос "чужих" textdraw'ов: если событие скрытия не пришло -- (или его вообще нет в этой версии либы), сами проверяем раз в 250мс, -- жив ли ещё каждый id из otherVisible, и чистим тех, кого уже нет. lua_thread.create(function() while true do wait(250) for id in pairs(otherVisible) do if not isTextdrawStillShown(id) then otherVisible[id] = nil end end end end) while true do wait(0) if (autoLocked and trackedVisible and next(otherVisible) == nil) or testMode then drawBar() end end end