script_name('Gambit Damage Informer') script_author('bro from sw') script_version('1.5.3') script_description('Damage informer, player effects, hitmarker HUD and damage learning') --[[ Автор скрипта: bro from sw. Оригинальную версию следует скачивать исключительно из раздела «Модификации» на форуме Gambit RP. Копии с других сайтов, из сборок и прочих источников могут содержать вирусы или иной вредоносный код. ]] --[[ Gambit Damage Informer for SA-MP / MoonLoader Installation: 1. Install MoonLoader, SAMP.Lua and mimgui. 2. Put this file into: GTA San Andreas\moonloader\ 3. Start SA-MP. Commands: /dmgmenu - open/close the visual settings menu (also F10) /dmginfo - enable/disable the whole informer /dmghud - enable/disable the hitmarker and floating damage numbers /dmgchat - enable/disable damage messages in the chat /dmglearn - enable/disable collection of clean incoming-hit samples /dmgstats - show the learning status for the current server /dmgspam - enable/disable compact rapid-hit messages /dmgsummary - enable/disable the end-of-combat summary /dmgprivacy - enable/disable strict nickname privacy checks /dmgdebug [ID] - show nickname-distance diagnostics /dmgreset confirm - reset learning for the current server /dmghelp - show all commands The script reads GiveDamage/TakeDamage events, local HP/armour and the server's SetPlayerHealth/SetPlayerArmour RPC values. It never sends, blocks or resends network packets and cannot alter damage. On Gambit RP the supplied table is used immediately. On every server, clean incoming hits are learned separately by server address, weapon, body part and armour state. A median is used after several samples. ]] local sampev = require 'lib.samp.events' local encoding = require 'encoding' local imgui = require 'mimgui' local vkeys = require 'vkeys' local memoryAvailable, memory = pcall(require, 'memory') if not memoryAvailable then memory = nil end encoding.default = 'CP1251' local u8 = encoding.UTF8 local MAX_PLAYER_HP = 150 local MAX_PLAYER_ARMOUR = 250 local informerEnabled = true local chatEnabled = true local hudEnabled = true local learningEnabled = true local antiSpamEnabled = true local combatSummaryEnabled = true local strictPrivacyEnabled = true local hitNameFlashEnabled = true local playerHealthBarEnabled = true local weaponActionTextEnabled = true -- The menu keeps its own ImGui pointers, while the Lua booleans above remain -- the single source of truth for commands, event handlers and persistence. local imguiNew = imgui.new local menuOpen = imguiNew.bool(false) local menuState = { informer = imguiNew.bool(informerEnabled), chat = imguiNew.bool(chatEnabled), hud = imguiNew.bool(hudEnabled), learning = imguiNew.bool(learningEnabled), antiSpam = imguiNew.bool(antiSpamEnabled), summary = imguiNew.bool(combatSummaryEnabled), privacy = imguiNew.bool(strictPrivacyEnabled), hitNameFlash = imguiNew.bool(hitNameFlashEnabled), playerHealthBar = imguiNew.bool(playerHealthBarEnabled), weaponActionText = imguiNew.bool(weaponActionTextEnabled) } local menuPlayerId = imguiNew.int(0) local menuPrivacyState = nil local menuStatusText = '' local menuStatusGood = true local menuStatusUntil = -100000 local menuResetPopupOpen = false -- Auto-learning configuration. All times are in milliseconds. local LEARNING_MIN_SAMPLES = 3 local LEARNING_MAX_SAMPLES = 15 local LEARNING_PREHIT_LOOKBACK = 350 local LEARNING_MIN_SETTLE_TIME = 700 local LEARNING_QUIET_TIME = 250 local LEARNING_MAX_WAIT = 2000 local LEARNING_SAVE_DELAY = 1500 local VITAL_EPSILON = 0.05 -- Chat aggregation and combat summary configuration. local CHAT_BURST_QUIET_TIME = 450 local CHAT_BURST_MAX_TIME = 1600 local INCOMING_MIN_SETTLE_TIME = 500 local INCOMING_VITAL_QUIET_TIME = 250 local INCOMING_MAX_WAIT = 2000 local COMBAT_SUMMARY_QUIET_TIME = 5000 local learningFilePath = getWorkingDirectory() .. '\\config\\damage_informer_learning.json' local settingsFilePath = getWorkingDirectory() .. '\\config\\damage_informer_settings.json' local learningData = { version = 1, servers = {} } local learningDataDirty = false local learningDataLoaded = false local learningSaveErrorShown = false local lastLearningSaveAt = 0 local settingsSaveErrorShown = false local currentServerKey = 'unknown' local currentServerName = '' local currentServerIsGambit = false -- The server sends the real nickname draw distance in InitGame. Keep it nil -- until that packet is received so an unknown threshold cannot reveal a name. local UNKNOWN_PLAYER_LABEL = 'НЕИЗВЕСТНЫЙ' local currentNametagDrawDistance = nil local currentServerShowsPlayerTags = nil local currentNametagLOS = nil local playerNametagShown = {} local lastIncomingPrivacySnapshot = nil -- One namespace keeps the main Lua chunk within Lua 5.1's local-variable -- limit while isolating every client-only player effect and owned patch. local playerEffects = { HIT_NAME_FLASH_DURATION = 600, WEAPON_CHANGE_CONFIRM_PACKETS = 2, WEAPON_ACTION_TEXT_DURATION = 1300, WEAPON_ACTION_TEXT_FADE_DURATION = 300, WEAPON_ACTION_MAX_DISTANCE = 20.0, WEAPON_ACTION_NAMES = { [1] = 'Brass Knuckles', [2] = 'Golf Club', [3] = 'Nightstick', [4] = 'Knife', [5] = 'Baseball Bat', [6] = 'Shovel', [7] = 'Pool Cue', [8] = 'Katana', [9] = 'Chainsaw', [10] = 'Purple Dildo', [11] = 'Dildo', [12] = 'Vibrator', [13] = 'Silver Vibrator', [14] = 'Flowers', [15] = 'Cane', [16] = 'Grenade', [17] = 'Tear Gas', [18] = 'Molotov', [22] = 'Colt 45', [23] = 'Silenced Pistol', [24] = 'Deagle', [25] = 'Shotgun', [26] = 'Sawed-Off', [27] = 'SPAS-12', [28] = 'UZI', [29] = 'MP5', [30] = 'AK-47', [31] = 'M4', [32] = 'TEC-9', [33] = 'Rifle', [34] = 'Sniper Rifle', [35] = 'RPG', [36] = 'Heat-Seeking RPG', [37] = 'Flamethrower', [38] = 'Minigun', [39] = 'Satchel Charge', [40] = 'Detonator', [41] = 'Spraycan', [42] = 'Fire Extinguisher', [43] = 'Camera', [44] = 'Night Vision', [45] = 'Thermal Vision', [46] = 'Parachute' }, HIT_NAME_COLOR_RGBA = 0xFF0000FF, SAMP_R1_SET_REMOTE_COLOR_OFFSET = 0x129D0, SAMP_R1_GET_REMOTE_COLOR_OFFSET = 0x129F0, SAMP_R1_SET_REMOTE_COLOR_SIGNATURE = 0x0424448B, SAMP_R1_GET_REMOTE_COLOR_SIGNATURE = 0xAB81B70F, SAMP_R1_COLOR_ARRAY_WRITER_OFFSET = 0xAD550, SAMP_R1_COLOR_ARRAY_WRITER_TAIL = 0xCC0008C2, SAMP_R1_PLAYER_COLOR_ARRAY_OFFSET = 0x216378, SAMP_R1_HEALTH_BAR_OFFSET = 0x689C0, SAMP_R1_HEALTH_BAR_ORIGINAL = 0x8B74EC83, SAMP_R1_HEALTH_BAR_SIGNATURE_2 = 0xD9782444, SAMP_R1_HEALTH_BAR_SIGNATURE_3 = 0x00842484, SAMP_R1_HEALTH_BAR_SIGNATURE_4 = 0x0DD80000, SAMP_R1_HEALTH_BAR_DISABLED = 0x900010C2, hitNameFlashes = {}, remoteWeaponStates = {}, weaponActionNotices = {}, sampR1VisualApi = nil, healthBarPatchOwned = false, healthBarPatchStatus = '', worldActionFont = nil } local pendingLearningHit = nil local lastObservedHealth = nil local lastObservedArmour = nil local lastVitalChangeAt = -100000 local recentLocalVitalWindow = nil local recentServerVitalWindow = nil local outgoingBursts = {} local incomingBurst = nil local combatSession = nil local burstSequence = 0 -- HUD configuration. Durations are in milliseconds. local HITMARKER_DURATION = 220 local DAMAGE_POPUP_DURATION = 950 local MAX_DAMAGE_POPUPS = 10 -- GTA:SA 1.0 US stores the normalized third-person crosshair position here. -- These values are only read. If a crosshair/widescreen fix changes them, -- the hitmarker follows the changed in-game sight position automatically. local CROSSHAIR_Y_ADDRESS = 0xB6EC10 local CROSSHAIR_X_ADDRESS = 0xB6EC14 local DEFAULT_CROSSHAIR_X = 0.53 local DEFAULT_CROSSHAIR_Y = 0.40 -- Scoped/first-person weapons use the screen centre instead of the -- third-person crosshair multipliers. local CENTERED_AIM_WEAPONS = { [34] = true, -- Sniper Rifle [35] = true, -- Rocket Launcher [36] = true -- Heat-Seeking Rocket Launcher } local damageFont = nil local hitmarker = { startedAt = -100000, headshot = false, x = nil, y = nil } local damagePopups = {} local popupSequence = 0 -- SA-MP body-part IDs: -- 3 torso, 4 groin, 5 left arm, 6 right arm, -- 7 left leg, 8 right leg, 9 head. local BODY_PARTS = { [3] = { key = 'torso', text = 'ТОРС' }, [4] = { key = 'groin', text = 'ПАХ' }, [5] = { key = 'arms', text = 'ЛЕВУЮ РУКУ' }, [6] = { key = 'arms', text = 'ПРАВУЮ РУКУ' }, [7] = { key = 'legs', text = 'ЛЕВУЮ НОГУ' }, [8] = { key = 'legs', text = 'ПРАВУЮ НОГУ' }, [9] = { key = 'head', text = 'ГОЛОВУ' } } -- Weapon IDs are the standard GTA:SA / SA-MP weapon IDs. -- Damage order: legs, arms, groin, torso, head. local DAMAGE_TABLE = { [0] = { name = 'Кулак', legs = 5, arms = 5, groin = 8, torso = 10, head = 15 }, [1] = { name = 'Кастет', legs = 10, arms = 10, groin = 12, torso = 15, head = 20 }, [22] = { name = 'Colt 1911', legs = 15, arms = 15, groin = 30, torso = 35, head = 60 }, [23] = { name = 'SD Pistol', legs = 20, arms = 20, groin = 35, torso = 40, head = 60 }, [24] = { name = 'Desert Eagle', legs = 20, arms = 20, groin = 40, torso = 60, head = 60 }, [25] = { name = 'Shotgun', legs = 20, arms = 20, groin = 40, torso = 60, head = 100 }, [26] = { name = 'Sawnoff Shotgun',legs = 15, arms = 15, groin = 30, torso = 35, head = 100 }, [28] = { name = 'UZI', legs = 15, arms = 15, groin = 30, torso = 35, head = 100 }, [29] = { name = 'MP5', legs = 15, arms = 15, groin = 30, torso = 35, head = 100 }, [30] = { name = 'AK-47', legs = 20, arms = 20, groin = 50, torso = 50, head = 80 }, [31] = { name = 'M4', legs = 20, arms = 20, groin = 50, torso = 50, head = 80 }, [32] = { name = 'TEC-9', legs = 15, arms = 15, groin = 30, torso = 35, head = 100 }, [33] = { name = 'Country Rifle', legs = 50, arms = 50, groin = 50, torso = 70, head = 90 }, [34] = { name = 'Sniper Rifle', legs = 60, arms = 60, groin = 60, torso = 70, head = 100 }, [41] = { name = 'Spraycan', legs = 0, arms = 0, groin = 0, torso = 0, head = 0 }, [42] = { name = 'Fire Ext.', legs = 0, arms = 0, groin = 0, torso = 0, head = 0 } } local function addChatMessage(text, color) sampAddChatMessage(u8:decode(text), color or 0xFFFFFFFF) end local function clamp(value, minimum, maximum) if value < minimum then return minimum end if value > maximum then return maximum end return value end local function isFiniteNumber(value) return type(value) == 'number' and value == value and value > -math.huge and value < math.huge end local function formatDamage(value) local rounded = math.floor(value + 0.5) if math.abs(value - rounded) < 0.05 then return tostring(rounded) end return string.format('%.1f', value) end local function roundToTenth(value) return math.floor(value * 10 + 0.5) / 10 end local function getWeaponName(weaponId) weaponId = tonumber(weaponId) or -1 local weapon = DAMAGE_TABLE[weaponId] return weapon and weapon.name or string.format('оружие ID %d', weaponId) end local function getLearningEntryKey(weaponId, bodyPartId, armourState) local bodyPart = BODY_PARTS[tonumber(bodyPartId) or -1] if not bodyPart or not armourState then return nil end return string.format('%d:%d:%s', tonumber(weaponId) or -1, tonumber(bodyPartId), armourState) end local function resetVitalTracking() pendingLearningHit = nil lastObservedHealth = nil lastObservedArmour = nil lastVitalChangeAt = -100000 recentLocalVitalWindow = nil recentServerVitalWindow = nil end local function resetNametagTracking() currentNametagDrawDistance = nil currentServerShowsPlayerTags = nil currentNametagLOS = nil playerNametagShown = {} lastIncomingPrivacySnapshot = nil menuPrivacyState = nil end local function getCurrentServerProfile(create) if type(learningData.servers) ~= 'table' then learningData.servers = {} end local profile = learningData.servers[currentServerKey] if type(profile) ~= 'table' and create then profile = { address = currentServerKey, isGambit = currentServerIsGambit, entries = {} } learningData.servers[currentServerKey] = profile learningDataDirty = true end if type(profile) == 'table' and type(profile.entries) ~= 'table' then if not create then return nil end profile.entries = {} learningDataDirty = true end return profile end local function getMedian(values) local copy = {} for _, value in ipairs(values or {}) do value = tonumber(value) if value and value == value and value > 0 and value <= MAX_PLAYER_HP then table.insert(copy, value) end end if #copy == 0 then return nil, 0 end table.sort(copy) local middle = math.floor(#copy / 2) if #copy % 2 == 1 then return copy[middle + 1], #copy end return (copy[middle] + copy[middle + 1]) / 2, #copy end local function ensureConfigDirectory() local configDirectory = getWorkingDirectory() .. '\\config' local ok, exists = pcall(doesDirectoryExist, configDirectory) if ok and not exists then pcall(createDirectory, configDirectory) end end local function loadSettingsData() ensureConfigDirectory() local file = io.open(settingsFilePath, 'r') if not file then return end local contents = file:read('*a') file:close() if type(contents) ~= 'string' or contents == '' then return end local ok, decoded = pcall(function() return decodeJson(contents) end) if not ok or type(decoded) ~= 'table' then addChatMessage('[Damage Informer] Файл настроек повреждён: используются значения по умолчанию.', 0xFFFFB070) return end if type(decoded.informerEnabled) == 'boolean' then informerEnabled = decoded.informerEnabled end if type(decoded.chatEnabled) == 'boolean' then chatEnabled = decoded.chatEnabled end if type(decoded.hudEnabled) == 'boolean' then hudEnabled = decoded.hudEnabled end if type(decoded.learningEnabled) == 'boolean' then learningEnabled = decoded.learningEnabled end if type(decoded.antiSpamEnabled) == 'boolean' then antiSpamEnabled = decoded.antiSpamEnabled end if type(decoded.combatSummaryEnabled) == 'boolean' then combatSummaryEnabled = decoded.combatSummaryEnabled end if type(decoded.strictPrivacyEnabled) == 'boolean' then strictPrivacyEnabled = decoded.strictPrivacyEnabled end if type(decoded.hitNameFlashEnabled) == 'boolean' then hitNameFlashEnabled = decoded.hitNameFlashEnabled end if type(decoded.playerHealthBarEnabled) == 'boolean' then playerHealthBarEnabled = decoded.playerHealthBarEnabled end if type(decoded.weaponActionTextEnabled) == 'boolean' then weaponActionTextEnabled = decoded.weaponActionTextEnabled end end local function saveSettingsData() ensureConfigDirectory() local ok, encoded = pcall(function() return encodeJson({ version = 1, informerEnabled = informerEnabled, chatEnabled = chatEnabled, hudEnabled = hudEnabled, learningEnabled = learningEnabled, antiSpamEnabled = antiSpamEnabled, combatSummaryEnabled = combatSummaryEnabled, strictPrivacyEnabled = strictPrivacyEnabled, hitNameFlashEnabled = hitNameFlashEnabled, playerHealthBarEnabled = playerHealthBarEnabled, weaponActionTextEnabled = weaponActionTextEnabled }) end) if not ok or type(encoded) ~= 'string' then if not settingsSaveErrorShown then addChatMessage('[Damage Informer] Не удалось кодировать настройки.', 0xFFFF8080) settingsSaveErrorShown = true end return false end local file = io.open(settingsFilePath, 'w') if not file then if not settingsSaveErrorShown then addChatMessage('[Damage Informer] Не удалось сохранить настройки.', 0xFFFF8080) settingsSaveErrorShown = true end return false end file:write(encoded) file:close() settingsSaveErrorShown = false return true end local function loadLearningData() learningDataLoaded = true ensureConfigDirectory() local file = io.open(learningFilePath, 'r') if not file then return end local contents = file:read('*a') file:close() if type(contents) ~= 'string' or contents == '' then return end local ok, decoded = pcall(function() return decodeJson(contents) end) if ok and type(decoded) == 'table' and type(decoded.servers) == 'table' then learningData = decoded learningData.version = 1 else addChatMessage('[Damage Informer] Файл обучения повреждён: начат новый профиль.', 0xFFFFB070) end end local function saveLearningData(force) if not learningDataDirty then return true end local now = getGameTimer() if not force and now - lastLearningSaveAt < LEARNING_SAVE_DELAY then return false end ensureConfigDirectory() local ok, encoded = pcall(function() return encodeJson(learningData) end) if not ok or type(encoded) ~= 'string' then if not learningSaveErrorShown then addChatMessage('[Damage Informer] Не удалось кодировать файл автообучения.', 0xFFFF8080) learningSaveErrorShown = true end return false end local file = io.open(learningFilePath, 'w') if not file then if not learningSaveErrorShown then addChatMessage('[Damage Informer] Не удалось сохранить файл автообучения.', 0xFFFF8080) learningSaveErrorShown = true end return false end file:write(encoded) file:close() learningDataDirty = false learningSaveErrorShown = false lastLearningSaveAt = now return true end local function refreshServerIdentity(hostName) local name = type(hostName) == 'string' and hostName or '' if name == '' then local ok, result = pcall(sampGetCurrentServerName) if ok and type(result) == 'string' then name = result end end local key = nil local ok, address, port = pcall(sampGetCurrentServerAddress) if ok and type(address) == 'string' and address ~= '' and tonumber(port) then key = string.format('%s:%d', address, tonumber(port)) end if not key then local safeName = name:gsub('[^%w%._%- ]', ''):sub(1, 64) key = 'name:' .. (safeName ~= '' and safeName or 'unknown') end local changed = key ~= currentServerKey currentServerKey = key currentServerName = name currentServerIsGambit = name:lower():find('gambit', 1, true) ~= nil if changed then resetVitalTracking() end if learningDataLoaded then local profile = getCurrentServerProfile(true) if profile and currentServerIsGambit and not profile.isGambit then profile.isGambit = true learningDataDirty = true elseif profile and profile.isGambit then currentServerIsGambit = true end end end local function isLocalPlayerSpawnedSafe() local ok, spawned = pcall(sampIsLocalPlayerSpawned) return not ok or spawned == true end local function readLocalVitals() local okHealth, health = pcall(getCharHealth, PLAYER_PED) local okArmour, armour = pcall(getCharArmour, PLAYER_PED) if not okHealth or not okArmour or type(health) ~= 'number' or type(armour) ~= 'number' then return nil, nil end return clamp(health, 0, MAX_PLAYER_HP), clamp(armour, 0, MAX_PLAYER_ARMOUR) end local function updateVitalWindow(window, now, beforeHealth, beforeArmour, afterHealth, afterArmour) if not window or now - window.lastChangeAt > LEARNING_PREHIT_LOOKBACK then return { startedAt = now, lastChangeAt = now, beforeHealth = beforeHealth, beforeArmour = beforeArmour, afterHealth = afterHealth, afterArmour = afterArmour } end window.lastChangeAt = now window.afterHealth = afterHealth window.afterArmour = afterArmour return window end local function observeLocalVitals(now) local health, armour = readLocalVitals() if not health then return nil, nil end if lastObservedHealth == nil or lastObservedArmour == nil then lastObservedHealth = health lastObservedArmour = armour lastVitalChangeAt = now return health, armour end if math.abs(health - lastObservedHealth) > VITAL_EPSILON or math.abs(armour - lastObservedArmour) > VITAL_EPSILON then local beforeHealth = lastObservedHealth local beforeArmour = lastObservedArmour lastObservedHealth = health lastObservedArmour = armour lastVitalChangeAt = now if incomingBurst then incomingBurst.localAfterHealth = health incomingBurst.localAfterArmour = armour if not incomingBurst.serverHealthSeen then incomingBurst.afterHealth = health end if not incomingBurst.serverArmourSeen then incomingBurst.afterArmour = armour end incomingBurst.lastVitalChangeAt = now end if pendingLearningHit then pendingLearningHit.localAfterHealth = health pendingLearningHit.localAfterArmour = armour if not pendingLearningHit.serverHealthSeen then pendingLearningHit.afterHealth = health end if not pendingLearningHit.serverArmourSeen then pendingLearningHit.afterArmour = armour end pendingLearningHit.lastChangeAt = now else recentLocalVitalWindow = updateVitalWindow( recentLocalVitalWindow, now, beforeHealth, beforeArmour, health, armour ) end end return health, armour end local function recordServerVital(kind, value) value = tonumber(value) if not value or value ~= value then return end if kind == 'health' then value = clamp(value, 0, MAX_PLAYER_HP) else value = clamp(value, 0, MAX_PLAYER_ARMOUR) end local now = getGameTimer() if incomingBurst then if kind == 'health' then incomingBurst.afterHealth = value incomingBurst.serverHealthSeen = true else incomingBurst.afterArmour = value incomingBurst.serverArmourSeen = true end incomingBurst.lastVitalChangeAt = now end if pendingLearningHit then if kind == 'health' then pendingLearningHit.afterHealth = value pendingLearningHit.serverHealthSeen = true else pendingLearningHit.afterArmour = value pendingLearningHit.serverArmourSeen = true end pendingLearningHit.lastChangeAt = now return end if not recentServerVitalWindow or now - recentServerVitalWindow.lastChangeAt > LEARNING_PREHIT_LOOKBACK then local health = lastObservedHealth or (kind == 'health' and value or 0) local armour = lastObservedArmour or (kind == 'armour' and value or 0) recentServerVitalWindow = { startedAt = now, lastChangeAt = now, beforeHealth = health, beforeArmour = armour, afterHealth = health, afterArmour = armour, healthSeen = false, armourSeen = false } end recentServerVitalWindow.lastChangeAt = now if kind == 'health' then recentServerVitalWindow.afterHealth = value recentServerVitalWindow.healthSeen = true else recentServerVitalWindow.afterArmour = value recentServerVitalWindow.armourSeen = true end end local function isRecentDamageWindow(window, now) if not window or now - window.lastChangeAt > LEARNING_PREHIT_LOOKBACK then return false end local beforeTotal = (window.beforeHealth or 0) + (window.beforeArmour or 0) local afterTotal = (window.afterHealth or 0) + (window.afterArmour or 0) return beforeTotal - afterTotal > VITAL_EPSILON end local function getPreHitVitals(now, health, armour) local beforeHealth = health local beforeArmour = armour local afterHealth = health local afterArmour = armour local serverHealthSeen = false local serverArmourSeen = false if isRecentDamageWindow(recentLocalVitalWindow, now) then beforeHealth = recentLocalVitalWindow.beforeHealth beforeArmour = recentLocalVitalWindow.beforeArmour afterHealth = recentLocalVitalWindow.afterHealth afterArmour = recentLocalVitalWindow.afterArmour end if isRecentDamageWindow(recentServerVitalWindow, now) then local serverBeforeTotal = recentServerVitalWindow.beforeHealth + recentServerVitalWindow.beforeArmour local chosenBeforeTotal = beforeHealth + beforeArmour if serverBeforeTotal >= chosenBeforeTotal then beforeHealth = recentServerVitalWindow.beforeHealth beforeArmour = recentServerVitalWindow.beforeArmour end if recentServerVitalWindow.healthSeen then afterHealth = recentServerVitalWindow.afterHealth serverHealthSeen = true end if recentServerVitalWindow.armourSeen then afterArmour = recentServerVitalWindow.afterArmour serverArmourSeen = true end end return beforeHealth, beforeArmour, afterHealth, afterArmour, serverHealthSeen, serverArmourSeen end local function captureIncomingSnapshot(now) local previousHealth = lastObservedHealth local previousArmour = lastObservedArmour local health, armour = observeLocalVitals(now) if not health then return nil end local beforeHealth, beforeArmour, afterHealth, afterArmour, serverHealthSeen, serverArmourSeen = getPreHitVitals(now, health, armour) if isFiniteNumber(previousHealth) and isFiniteNumber(previousArmour) then local previousTotal = previousHealth + previousArmour local selectedTotal = beforeHealth + beforeArmour if previousTotal > selectedTotal + VITAL_EPSILON then beforeHealth = previousHealth beforeArmour = previousArmour end end recentLocalVitalWindow = nil recentServerVitalWindow = nil return { beforeHealth = beforeHealth, beforeArmour = beforeArmour, afterHealth = afterHealth, afterArmour = afterArmour, localAfterHealth = health, localAfterArmour = armour, serverHealthSeen = serverHealthSeen, serverArmourSeen = serverArmourSeen } end local function getArmourState(direction, playerId) if direction == 'incoming' then if pendingLearningHit then return pendingLearningHit.armourState end local _, armour = readLocalVitals() return armour and armour > VITAL_EPSILON and 'armoured' or 'unarmoured' end playerId = tonumber(playerId) if playerId and playerId >= 0 and playerId <= 1003 then local ok, armour = pcall(sampGetPlayerArmor, playerId) if ok and type(armour) == 'number' then return armour > VITAL_EPSILON and 'armoured' or 'unarmoured' end end return nil end local function getLearnedDamage(weaponId, bodyPartId, armourState) local entryKey = getLearningEntryKey(weaponId, bodyPartId, armourState) local profile = entryKey and getCurrentServerProfile(false) or nil local entry = profile and profile.entries[entryKey] or nil if type(entry) ~= 'table' or type(entry.samples) ~= 'table' then return nil, 0, 0 end local typical, sampleCount = getMedian(entry.samples) local totalSamples = tonumber(entry.totalSamples) or sampleCount if not typical or sampleCount < LEARNING_MIN_SAMPLES then return nil, sampleCount, totalSamples end return roundToTenth(typical), sampleCount, totalSamples end local function addLearningSample(hit, damage) local profile = getCurrentServerProfile(true) local entryKey = getLearningEntryKey(hit.weaponId, hit.bodyPartId, hit.armourState) if not profile or not entryKey then return end local entry = profile.entries[entryKey] if type(entry) ~= 'table' then entry = { samples = {}, totalSamples = 0 } profile.entries[entryKey] = entry end if type(entry.samples) ~= 'table' then entry.samples = {} end local cleanSamples = {} for _, value in ipairs(entry.samples) do value = tonumber(value) if value and value == value and value > 0 and value <= MAX_PLAYER_HP then table.insert(cleanSamples, roundToTenth(value)) end end entry.samples = cleanSamples table.insert(entry.samples, roundToTenth(damage)) while #entry.samples > LEARNING_MAX_SAMPLES do table.remove(entry.samples, 1) end entry.totalSamples = (tonumber(entry.totalSamples) or 0) + 1 local typical = getMedian(entry.samples) entry.typicalDamage = typical and roundToTenth(typical) or nil entry.weaponId = hit.weaponId entry.bodyPartId = hit.bodyPartId entry.armourState = hit.armourState profile.updatedAt = os.time() learningDataDirty = true local total = entry.totalSamples if chatEnabled and (total == 1 or total == 3 or total == 5 or total == 10) then local armourText = hit.armourState == 'armoured' and 'с бронёй' or 'без брони' local readyText = #entry.samples >= LEARNING_MIN_SAMPLES and ' — ГОТОВО' or '' addChatMessage(string.format( '[DMG LEARN] %s / %s / %s: типичный урон %s, замеров %d%s', getWeaponName(hit.weaponId), BODY_PARTS[hit.bodyPartId].text, armourText, formatDamage(entry.typicalDamage or damage), total, readyText ), 0xFF80D8FF) end end local function beginLearningHit(playerId, weaponId, bodyPartId, snapshot, now) if not learningEnabled or not BODY_PARTS[tonumber(bodyPartId) or -1] then return end playerId = tonumber(playerId) or 65535 if playerId == 65535 then return end if type(snapshot) ~= 'table' then return end now = tonumber(now) or getGameTimer() local health = tonumber(snapshot.localAfterHealth) if not health or health <= VITAL_EPSILON then return end if pendingLearningHit then pendingLearningHit.contaminated = true pendingLearningHit.hitCount = pendingLearningHit.hitCount + 1 pendingLearningHit.lastChangeAt = now return end local beforeHealth = tonumber(snapshot.beforeHealth) local beforeArmour = tonumber(snapshot.beforeArmour) local afterHealth = tonumber(snapshot.afterHealth) local afterArmour = tonumber(snapshot.afterArmour) if not beforeHealth or not beforeArmour or not afterHealth or not afterArmour then return end pendingLearningHit = { startedAt = now, lastChangeAt = now, playerId = playerId, weaponId = tonumber(weaponId) or -1, bodyPartId = tonumber(bodyPartId) or -1, beforeHealth = beforeHealth, beforeArmour = beforeArmour, afterHealth = afterHealth, afterArmour = afterArmour, localAfterHealth = health, localAfterArmour = tonumber(snapshot.localAfterArmour) or afterArmour, serverHealthSeen = snapshot.serverHealthSeen == true, serverArmourSeen = snapshot.serverArmourSeen == true, armourState = beforeArmour > VITAL_EPSILON and 'armoured' or 'unarmoured', contaminated = false, hitCount = 1 } end local function finishLearningHit() local hit = pendingLearningHit pendingLearningHit = nil recentLocalVitalWindow = nil recentServerVitalWindow = nil if not hit or hit.contaminated then return end local beforeHealth = tonumber(hit.beforeHealth) local beforeArmour = tonumber(hit.beforeArmour) local afterHealth = tonumber(hit.afterHealth) local afterArmour = tonumber(hit.afterArmour) if not beforeHealth or not beforeArmour or not afterHealth or not afterArmour then return end -- Death truncates the visible HP loss, so an overkill hit is not a clean sample. if afterHealth <= VITAL_EPSILON then return end -- Healing, spawn resets and armour grants inside the measurement window -- make the hit ambiguous. Server correction is allowed as long as the final -- value remains below the pre-hit value. if afterHealth > beforeHealth + VITAL_EPSILON or afterArmour > beforeArmour + VITAL_EPSILON then return end local healthLost = math.max(0, beforeHealth - afterHealth) local armourLost = math.max(0, beforeArmour - afterArmour) local damage = roundToTenth(healthLost + armourLost) if damage <= VITAL_EPSILON or damage > MAX_PLAYER_HP then return end addLearningSample(hit, damage) end local function updateLearning() local now = getGameTimer() if not isLocalPlayerSpawnedSafe() then if pendingLearningHit then pendingLearningHit = nil end lastObservedHealth = nil lastObservedArmour = nil return end observeLocalVitals(now) if pendingLearningHit then local age = now - pendingLearningHit.startedAt local quietFor = now - pendingLearningHit.lastChangeAt if age >= LEARNING_MAX_WAIT or (age >= LEARNING_MIN_SETTLE_TIME and quietFor >= LEARNING_QUIET_TIME) then finishLearningHit() end end if recentLocalVitalWindow and now - recentLocalVitalWindow.lastChangeAt > LEARNING_PREHIT_LOOKBACK then recentLocalVitalWindow = nil end if recentServerVitalWindow and now - recentServerVitalWindow.lastChangeAt > LEARNING_PREHIT_LOOKBACK then recentServerVitalWindow = nil end saveLearningData(false) end local function getLearningStats() local profile = getCurrentServerProfile(false) local combinations = 0 local ready = 0 local totalSamples = 0 if not profile then return combinations, ready, totalSamples end for _, entry in pairs(profile.entries) do if type(entry) == 'table' and type(entry.samples) == 'table' then combinations = combinations + 1 local _, count = getMedian(entry.samples) if count >= LEARNING_MIN_SAMPLES then ready = ready + 1 end totalSamples = totalSamples + (tonumber(entry.totalSamples) or count) end end return combinations, ready, totalSamples end local function resetCurrentServerLearning() if currentServerKey == 'unknown' or currentServerKey == 'name:unknown' then return false, 0, 0, 'unknown' end local profile = getCurrentServerProfile(false) if not profile then return true, 0, 0 end local combinations = 0 local samples = 0 if profile and type(profile.entries) == 'table' then for _, entry in pairs(profile.entries) do combinations = combinations + 1 if type(entry) == 'table' and type(entry.samples) == 'table' then samples = samples + (tonumber(entry.totalSamples) or #entry.samples) end end end local oldEntries = profile.entries local oldUpdatedAt = profile.updatedAt local wasDirty = learningDataDirty profile.entries = {} profile.updatedAt = os.time() learningDataDirty = true pendingLearningHit = nil recentLocalVitalWindow = nil recentServerVitalWindow = nil if not saveLearningData(true) then profile.entries = oldEntries profile.updatedAt = oldUpdatedAt learningDataDirty = wasDirty return false, combinations, samples, 'save' end return true, combinations, samples end local function makeArgb(alpha, red, green, blue) alpha = clamp(math.floor(alpha + 0.5), 0, 255) red = clamp(math.floor(red + 0.5), 0, 255) green = clamp(math.floor(green + 0.5), 0, 255) blue = clamp(math.floor(blue + 0.5), 0, 255) return alpha * 0x1000000 + red * 0x10000 + green * 0x100 + blue end function playerEffects.normalizeUInt32(value) value = tonumber(value) if not value or value ~= value then return nil end return value % 0x100000000 end function playerEffects.getSampModuleBase() local ok, base = pcall(getModuleHandle, 'samp.dll') base = ok and tonumber(base) or nil if not base or base <= 0 then return nil end return base end function playerEffects.readUInt32(address) if not memory or not address then return nil end local ok, value = pcall(memory.getuint32, address, false) if not ok then return nil end return playerEffects.normalizeUInt32(value) end function playerEffects.writeUInt32(address, value) if not memory or not address then return false end local normalized = playerEffects.normalizeUInt32(value) local ok = pcall(memory.setuint32, address, normalized, true) return ok and playerEffects.readUInt32(address) == normalized end function playerEffects.getSampR1VisualApi() if playerEffects.sampR1VisualApi ~= nil then return playerEffects.sampR1VisualApi or nil end local base = playerEffects.getSampModuleBase() local colorArray = base and base + playerEffects.SAMP_R1_PLAYER_COLOR_ARRAY_OFFSET or nil local writer = base and base + playerEffects.SAMP_R1_COLOR_ARRAY_WRITER_OFFSET or nil if not base or playerEffects.readUInt32(base + playerEffects.SAMP_R1_SET_REMOTE_COLOR_OFFSET) ~= playerEffects.SAMP_R1_SET_REMOTE_COLOR_SIGNATURE or playerEffects.readUInt32(base + playerEffects.SAMP_R1_GET_REMOTE_COLOR_OFFSET) ~= playerEffects.SAMP_R1_GET_REMOTE_COLOR_SIGNATURE -- The installed R1 setter embeds the relocated absolute address of the -- color array. Validate it before any write so another samp.dll build -- can never turn this cosmetic effect into an unsafe memory patch. or playerEffects.readUInt32(writer + 18) ~= playerEffects.normalizeUInt32(colorArray) or playerEffects.readUInt32(writer + 22) ~= playerEffects.SAMP_R1_COLOR_ARRAY_WRITER_TAIL then playerEffects.sampR1VisualApi = false return nil end local api = { base = base, colorArray = colorArray } playerEffects.sampR1VisualApi = api return api end function playerEffects.getRemotePlayerColor(playerId) local api = playerEffects.getSampR1VisualApi() playerId = tonumber(playerId) if not api or not playerId or playerId < 0 or playerId > 1003 then return nil end return playerEffects.readUInt32(api.colorArray + playerId * 4) end function playerEffects.setRemotePlayerColor(playerId, color) local api = playerEffects.getSampR1VisualApi() playerId = tonumber(playerId) color = playerEffects.normalizeUInt32(color) if not api or not playerId or playerId < 0 or playerId > 1003 or not color then return false end return playerEffects.writeUInt32(api.colorArray + playerId * 4, color) end function playerEffects.restoreHitNameFlash(playerId) local flash = playerEffects.hitNameFlashes[playerId] if not flash then return end local currentColor = playerEffects.getRemotePlayerColor(playerId) if currentColor == playerEffects.HIT_NAME_COLOR_RGBA then playerEffects.setRemotePlayerColor(playerId, flash.restoreColor) end playerEffects.hitNameFlashes[playerId] = nil end function playerEffects.restoreAllHitNameFlashes() local playerIds = {} for playerId in pairs(playerEffects.hitNameFlashes) do table.insert(playerIds, playerId) end for _, playerId in ipairs(playerIds) do playerEffects.restoreHitNameFlash(playerId) end end function playerEffects.registerHitNameFlash(playerId, now) if not informerEnabled or not hitNameFlashEnabled then return end playerId = tonumber(playerId) if not playerId or playerId < 0 or playerId > 1003 then return end local currentColor = playerEffects.getRemotePlayerColor(playerId) if not currentColor then return end local flash = playerEffects.hitNameFlashes[playerId] if not flash then flash = { restoreColor = currentColor, expiresAt = now + playerEffects.HIT_NAME_FLASH_DURATION } playerEffects.hitNameFlashes[playerId] = flash else if currentColor ~= playerEffects.HIT_NAME_COLOR_RGBA then flash.restoreColor = currentColor end flash.expiresAt = now + playerEffects.HIT_NAME_FLASH_DURATION end if currentColor ~= playerEffects.HIT_NAME_COLOR_RGBA and not playerEffects.setRemotePlayerColor( playerId, playerEffects.HIT_NAME_COLOR_RGBA ) then playerEffects.hitNameFlashes[playerId] = nil end end function playerEffects.updateHitNameFlashes(now) local expired = {} for playerId, flash in pairs(playerEffects.hitNameFlashes) do if now >= flash.expiresAt then table.insert(expired, playerId) elseif flash.reapplyAt and now >= flash.reapplyAt then local currentColor = playerEffects.getRemotePlayerColor(playerId) if currentColor and currentColor ~= playerEffects.HIT_NAME_COLOR_RGBA then playerEffects.setRemotePlayerColor( playerId, playerEffects.HIT_NAME_COLOR_RGBA ) end flash.reapplyAt = nil end end for _, playerId in ipairs(expired) do playerEffects.restoreHitNameFlash(playerId) end end function playerEffects.healthBarSignatureMatches(address) return playerEffects.readUInt32(address + 4) == playerEffects.SAMP_R1_HEALTH_BAR_SIGNATURE_2 and playerEffects.readUInt32(address + 8) == playerEffects.SAMP_R1_HEALTH_BAR_SIGNATURE_3 and playerEffects.readUInt32(address + 12) == playerEffects.SAMP_R1_HEALTH_BAR_SIGNATURE_4 end function playerEffects.applyPlayerHealthBarVisibility(visible) visible = visible == true local base = playerEffects.getSampModuleBase() if not memory or not base then playerEffects.healthBarPatchStatus = 'Патч недоступен: модуль memory или samp.dll не найден.' return false end local address = base + playerEffects.SAMP_R1_HEALTH_BAR_OFFSET local current = playerEffects.readUInt32(address) if not current then playerEffects.healthBarPatchStatus = 'Патч недоступен: не удалось прочитать samp.dll.' return false end if not playerEffects.healthBarSignatureMatches(address) then playerEffects.healthBarPatchStatus = 'Версия samp.dll не поддерживается; память не изменена.' return false end if visible then if current == playerEffects.SAMP_R1_HEALTH_BAR_ORIGINAL then playerEffects.healthBarPatchOwned = false playerEffects.healthBarPatchStatus = 'Стандартная полоска HP/брони отображается.' return true end if current == playerEffects.SAMP_R1_HEALTH_BAR_DISABLED and playerEffects.healthBarPatchOwned then if playerEffects.writeUInt32( address, playerEffects.SAMP_R1_HEALTH_BAR_ORIGINAL ) then playerEffects.healthBarPatchOwned = false playerEffects.healthBarPatchStatus = 'Стандартная полоска HP/брони отображается.' return true end end playerEffects.healthBarPatchStatus = 'Полоска изменена другим модом; скрипт не вмешивается.' return false end if current == playerEffects.SAMP_R1_HEALTH_BAR_DISABLED then playerEffects.healthBarPatchStatus = playerEffects.healthBarPatchOwned and 'Полоска HP/брони скрыта этим скриптом.' or 'Полоска HP/брони уже скрыта другим модом.' return true end if current ~= playerEffects.SAMP_R1_HEALTH_BAR_ORIGINAL then playerEffects.healthBarPatchStatus = 'Версия samp.dll не поддерживается; память не изменена.' return false end if not playerEffects.writeUInt32( address, playerEffects.SAMP_R1_HEALTH_BAR_DISABLED ) then playerEffects.healthBarPatchStatus = 'Не удалось безопасно скрыть полоску HP/брони.' return false end playerEffects.healthBarPatchOwned = true playerEffects.healthBarPatchStatus = 'Полоска HP/брони скрыта этим скриптом.' return true end function playerEffects.restoreOwnedHealthBarPatch() if not playerEffects.healthBarPatchOwned then return true end return playerEffects.applyPlayerHealthBarVisibility(true) end function playerEffects.reconcileHealthBarSetting(notify) local applied = playerEffects.applyPlayerHealthBarVisibility( (not informerEnabled) or playerHealthBarEnabled ) if applied then return true end if informerEnabled and not playerHealthBarEnabled then playerHealthBarEnabled = true menuState.playerHealthBar[0] = true saveSettingsData() end if notify then addChatMessage( '[Damage Informer] Полоска HP: ' .. playerEffects.healthBarPatchStatus, 0xFFFF8080 ) end return false end local function readCrosshairMultiplier(address, fallback) if not memory then return fallback end local ok, value = pcall(memory.getfloat, address, false) if ok and type(value) == 'number' and value == value and value >= 0.10 and value <= 0.90 then return value end return fallback end local function getCrosshairPosition(weaponId) local screenWidth, screenHeight = getScreenResolution() if CENTERED_AIM_WEAPONS[tonumber(weaponId) or -1] then return screenWidth / 2, screenHeight / 2 end local multiplierX = readCrosshairMultiplier(CROSSHAIR_X_ADDRESS, DEFAULT_CROSSHAIR_X) local multiplierY = readCrosshairMultiplier(CROSSHAIR_Y_ADDRESS, DEFAULT_CROSSHAIR_Y) return screenWidth * multiplierX, screenHeight * multiplierY end local function clearDamageHud() hitmarker.startedAt = -100000 hitmarker.headshot = false hitmarker.x = nil hitmarker.y = nil damagePopups = {} end local function registerDamageHud(damage, bodyPartId, weaponId, source) if not informerEnabled or not hudEnabled then return end local now = getGameTimer() local isHeadshot = tonumber(bodyPartId) == 9 local crosshairX, crosshairY = getCrosshairPosition(weaponId) hitmarker.startedAt = now hitmarker.headshot = isHeadshot hitmarker.x = crosshairX hitmarker.y = crosshairY popupSequence = popupSequence + 1 local horizontalOffsets = { 0, -24, 24, -12, 12, -36, 36 } local offsetIndex = ((popupSequence - 1) % #horizontalOffsets) + 1 table.insert(damagePopups, { startedAt = now, damage = damage, headshot = isHeadshot, fallback = source == 'rpc', xOffset = horizontalOffsets[offsetIndex], anchorX = crosshairX, anchorY = crosshairY }) while #damagePopups > MAX_DAMAGE_POPUPS do table.remove(damagePopups, 1) end end local function drawHitmarker(scale, now) local elapsed = now - hitmarker.startedAt if elapsed < 0 or elapsed >= HITMARKER_DURATION or not hitmarker.x or not hitmarker.y then return end local progress = elapsed / HITMARKER_DURATION local centerX = hitmarker.x local centerY = hitmarker.y local alpha = 255 * (1 - progress) local gap = (5 + progress * 3) * scale local length = 8 * scale local width = math.max(1.5, 2 * scale) local color if hitmarker.headshot then color = makeArgb(alpha, 255, 70, 70) else color = makeArgb(alpha, 255, 255, 255) end renderDrawLine( centerX - gap - length, centerY - gap - length, centerX - gap, centerY - gap, width, color ) renderDrawLine( centerX + gap, centerY - gap, centerX + gap + length, centerY - gap - length, width, color ) renderDrawLine( centerX - gap - length, centerY + gap + length, centerX - gap, centerY + gap, width, color ) renderDrawLine( centerX + gap, centerY + gap, centerX + gap + length, centerY + gap + length, width, color ) end local function drawDamagePopups(scale, now) if not damageFont then return end for index = #damagePopups, 1, -1 do local popup = damagePopups[index] local elapsed = now - popup.startedAt if elapsed < 0 or elapsed >= DAMAGE_POPUP_DURATION then table.remove(damagePopups, index) else local progress = elapsed / DAMAGE_POPUP_DURATION local rise = 30 * progress * scale local alpha = 255 if progress > 0.60 then alpha = 255 * (1 - progress) / 0.40 end local text = (popup.fallback and '~-' or '-') .. formatDamage(popup.damage) local textWidth = renderGetFontDrawTextLength(damageFont, text, true) local x = popup.anchorX + popup.xOffset * scale - textWidth / 2 local y = popup.anchorY - 42 * scale - rise local color if popup.headshot then color = makeArgb(alpha, 255, 70, 70) else color = makeArgb(alpha, 255, 220, 70) end renderFontDrawText(damageFont, text, x, y, color, true) end end end local function drawDamageHud() if not informerEnabled or not hudEnabled then return end local _, screenHeight = getScreenResolution() local scale = clamp(screenHeight / 1080, 0.75, 1.50) local now = getGameTimer() drawHitmarker(scale, now) drawDamagePopups(scale, now) end local function inspectIncomingPlayer(playerId, forceStrict) playerId = tonumber(playerId) local strict = forceStrict == true or strictPrivacyEnabled local state = { capturedAt = getGameTimer(), playerId = playerId, connected = false, streamed = false, distance = nil, drawDistance = currentNametagDrawDistance, globalTags = currentServerShowsPlayerTags, playerTagOverride = nil, nametagLOS = currentNametagLOS, losClear = nil, strict = strict, allowed = false, reason = 'НЕКОРРЕКТНЫЙ ID' } if playerId then state.playerTagOverride = playerNametagShown[playerId] end if not playerId or playerId == 65535 or playerId < 0 or playerId > 1003 then return state end local okConnected, connected = pcall(sampIsPlayerConnected, playerId) state.connected = okConnected and connected == true if not state.connected then state.reason = 'ИГРОК НЕ ПОДКЛЮЧЁН' return state end if not isFiniteNumber(currentNametagDrawDistance) or currentNametagDrawDistance < 0 then state.reason = 'ДИСТАНЦИЯ СЕРВЕРА НЕ ПОЛУЧЕНА' return state end local okHandle, found, playerPed = pcall(sampGetCharHandleBySampPlayerId, playerId) state.streamed = okHandle and found == true and playerPed ~= nil if not state.streamed then state.reason = 'ИГРОК НЕ ПРОГРУЖЕН' return state end local okLocal, localX, localY, localZ = pcall(getCharCoordinates, PLAYER_PED) local okPlayer, playerX, playerY, playerZ = pcall(getCharCoordinates, playerPed) if not okLocal or not okPlayer or not isFiniteNumber(localX) or not isFiniteNumber(localY) or not isFiniteNumber(localZ) or not isFiniteNumber(playerX) or not isFiniteNumber(playerY) or not isFiniteNumber(playerZ) then state.reason = 'КООРДИНАТЫ НЕДОСТУПНЫ' return state end state.playerPed = playerPed state.playerX = playerX state.playerY = playerY state.playerZ = playerZ local deltaX = playerX - localX local deltaY = playerY - localY local deltaZ = playerZ - localZ local distanceSquared = deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ state.distance = math.sqrt(distanceSquared) if distanceSquared > currentNametagDrawDistance * currentNametagDrawDistance then state.reason = 'ВНЕ ДИСТАНЦИИ НИКНЕЙМА' return state end if strict and currentServerShowsPlayerTags ~= true then state.reason = 'НИКИ ОТКЛЮЧЕНЫ СЕРВЕРОМ' return state end if strict and playerNametagShown[playerId] == false then state.reason = 'НИК ИГРОКА СКРЫТ СЕРВЕРОМ' return state end if strict and currentNametagLOS == true then local okCamera, cameraX, cameraY, cameraZ = pcall(getActiveCameraCoordinates) if not okCamera or not isFiniteNumber(cameraX) or not isFiniteNumber(cameraY) or not isFiniteNumber(cameraZ) then state.reason = 'LOS НЕДОСТУПНА' return state end local okLos, blocked = pcall( processLineOfSight, cameraX, cameraY, cameraZ, playerX, playerY, playerZ + 0.8, true, false, false, true, true, false, false, false ) if not okLos or type(blocked) ~= 'boolean' then state.reason = 'LOS НЕДОСТУПНА' return state end state.losClear = not blocked if not state.losClear then state.reason = 'ЛИНИЯ ВИДИМОСТИ ПЕРЕКРЫТА' return state end end state.allowed = true state.reason = 'РАЗРЕШЕНО' return state end local function getPlayerLabel(playerId) playerId = tonumber(playerId) or 65535 if playerId == 65535 then return 'НЕИЗВЕСТНЫЙ ИСТОЧНИК' end if playerId >= 0 and playerId <= 1003 and sampIsPlayerConnected(playerId) then local ok, nickname = pcall(sampGetPlayerNickname, playerId) if ok and type(nickname) == 'string' and nickname ~= '' then return string.format('%s [%d]', nickname, playerId) end end return string.format('ИГРОКА ID %d', playerId) end local function getIncomingPlayerLabel(playerId) local state = inspectIncomingPlayer(playerId) if not state.allowed then lastIncomingPrivacySnapshot = state return UNKNOWN_PLAYER_LABEL, state end local ok, nickname = pcall(sampGetPlayerNickname, state.playerId) if not ok or type(nickname) ~= 'string' or nickname == '' then state.allowed = false state.reason = 'НИК НЕДОСТУПЕН' lastIncomingPrivacySnapshot = state return UNKNOWN_PLAYER_LABEL, state end lastIncomingPrivacySnapshot = state return string.format('%s [%d]', nickname, state.playerId), state end function playerEffects.clearPlayerVisualState(playerId, restoreColor) playerId = tonumber(playerId) if not playerId then return end if restoreColor then playerEffects.restoreHitNameFlash(playerId) else playerEffects.hitNameFlashes[playerId] = nil end playerEffects.remoteWeaponStates[playerId] = nil playerEffects.weaponActionNotices[playerId] = nil end function playerEffects.clearAllPlayerVisualState(restoreColors) if restoreColors then playerEffects.restoreAllHitNameFlashes() else playerEffects.hitNameFlashes = {} end playerEffects.remoteWeaponStates = {} playerEffects.weaponActionNotices = {} end function playerEffects.seedRemoteWeaponMode(playerId, mode, weaponId) playerId = tonumber(playerId) if not playerId or playerId < 0 or playerId > 1003 then return end weaponId = tonumber(weaponId) if weaponId then weaponId = weaponId % 64 end local existing = playerEffects.remoteWeaponStates[playerId] if mode == 'vehicle' and existing and existing.mode == 'vehicle' then existing.confirmedWeapon = weaponId existing.candidateWeapon = nil existing.candidateCount = 0 playerEffects.weaponActionNotices[playerId] = nil return end playerEffects.remoteWeaponStates[playerId] = { mode = mode, confirmedWeapon = weaponId, candidateWeapon = nil, candidateCount = 0 } playerEffects.weaponActionNotices[playerId] = nil end function playerEffects.observeRemoteOnFootWeapon(playerId, weaponId, now) playerId = tonumber(playerId) weaponId = tonumber(weaponId) if not playerId or playerId < 0 or playerId > 1003 or not weaponId then return end -- SA-MP packs two special-key bits into the same byte as the 6-bit -- weapon ID. Ignore those upper bits so Y/N/H presses are not a scroll. weaponId = weaponId % 64 local state = playerEffects.remoteWeaponStates[playerId] if not state or state.mode ~= 'onfoot' or state.confirmedWeapon == nil then playerEffects.seedRemoteWeaponMode(playerId, 'onfoot', weaponId) return end if weaponId == state.confirmedWeapon then state.candidateWeapon = nil state.candidateCount = 0 return end if state.candidateWeapon == weaponId then state.candidateCount = state.candidateCount + 1 else state.candidateWeapon = weaponId state.candidateCount = 1 end if state.candidateCount < playerEffects.WEAPON_CHANGE_CONFIRM_PACKETS then return end state.confirmedWeapon = weaponId state.candidateWeapon = nil state.candidateCount = 0 -- Every confirmed switch replaces the previous notice. Fists and internal -- weapon IDs therefore also remove stale text from the former weapon. playerEffects.weaponActionNotices[playerId] = nil local weaponName = playerEffects.WEAPON_ACTION_NAMES[weaponId] if informerEnabled and weaponActionTextEnabled and weaponName then -- The server nametag rules are mandatory, with an additional close -- range cap so a large server draw distance cannot expose this RP text. local visibility = inspectIncomingPlayer(playerId, true) if not visibility.allowed or not isFiniteNumber(visibility.distance) or visibility.distance > playerEffects.WEAPON_ACTION_MAX_DISTANCE then return end playerEffects.weaponActionNotices[playerId] = { weaponId = weaponId, weaponName = weaponName, startedAt = now, expiresAt = now + playerEffects.WEAPON_ACTION_TEXT_DURATION } end end function playerEffects.drawWeaponActionNotices(now) if not informerEnabled or not weaponActionTextEnabled or not playerEffects.worldActionFont then return end local screenWidth, screenHeight = getScreenResolution() local expired = {} for playerId, notice in pairs(playerEffects.weaponActionNotices) do if now >= notice.expiresAt then table.insert(expired, playerId) else -- Custom overhead text is always stricter than chat privacy: it is -- drawn only where the native nickname itself is allowed to exist. local visibility = inspectIncomingPlayer(playerId, true) if visibility.allowed and isFiniteNumber(visibility.distance) and visibility.distance <= playerEffects.WEAPON_ACTION_MAX_DISTANCE and type(notice.weaponName) == 'string' and notice.weaponName ~= '' then local okOnScreen, onScreen = pcall(isCharOnScreen, visibility.playerPed) local okProjection, screenX, screenY = pcall( convert3DCoordsToScreen, visibility.playerX, visibility.playerY, visibility.playerZ + 1.15 ) if okOnScreen and onScreen == true and okProjection and isFiniteNumber(screenX) and isFiniteNumber(screenY) and screenX >= 0 and screenX <= screenWidth and screenY >= 0 and screenY <= screenHeight then local remaining = notice.expiresAt - now local alpha = 255 if remaining < playerEffects.WEAPON_ACTION_TEXT_FADE_DURATION then alpha = 255 * remaining / playerEffects.WEAPON_ACTION_TEXT_FADE_DURATION end local text = u8:decode( '* достаёт оружие ' .. notice.weaponName .. ' *' ) local textWidth = renderGetFontDrawTextLength( playerEffects.worldActionFont, text, true ) renderFontDrawText( playerEffects.worldActionFont, text, screenX - textWidth / 2, screenY - 18, makeArgb(alpha, 220, 190, 255), true ) end end end end for _, playerId in ipairs(expired) do playerEffects.weaponActionNotices[playerId] = nil end end local function resolveDamage(direction, playerId, weaponId, bodyPartId, rpcDamage, armourStateOverride) weaponId = tonumber(weaponId) or -1 bodyPartId = tonumber(bodyPartId) or -1 local weapon = DAMAGE_TABLE[weaponId] local bodyPart = BODY_PARTS[bodyPartId] local armourState = armourStateOverride or getArmourState(direction, playerId) local learnedDamage, _, totalSamples = getLearnedDamage(weaponId, bodyPartId, armourState) if learnedDamage then return clamp(learnedDamage, 0, MAX_PLAYER_HP), getWeaponName(weaponId), 'learned', totalSamples end -- The supplied Gambit table is an immediate fallback until this exact -- weapon/body/armour combination has enough clean measurements. if currentServerIsGambit and weapon and bodyPart then local serverDamage = weapon[bodyPart.key] if type(serverDamage) == 'number' then return clamp(serverDamage, 0, MAX_PLAYER_HP), weapon.name, 'gambit', 0 end end -- Until the server profile is ready, show the original client RPC value -- and mark it clearly. It is not presented as measured server damage. local fallback = clamp(tonumber(rpcDamage) or 0, 0, MAX_PLAYER_HP) return fallback, getWeaponName(weaponId), 'rpc', 0 end local function getBodyPartText(bodyPartId) local bodyPart = BODY_PARTS[tonumber(bodyPartId) or -1] if bodyPart then return bodyPart.text end return string.format('НЕИЗВЕСТНУЮ ЧАСТЬ ТЕЛА (ID %s)', tostring(bodyPartId)) end local function addOrderedCount(counts, order, key) key = tostring(key) if not counts[key] then counts[key] = 0 table.insert(order, key) end counts[key] = counts[key] + 1 end local function formatOrderedCounts(counts, order, maximumItems) local parts = {} maximumItems = maximumItems or #order for index, key in ipairs(order) do if index > maximumItems then break end local count = counts[key] or 0 if count > 1 then table.insert(parts, string.format('%s x%d', key, count)) else table.insert(parts, key) end end if #order > maximumItems then table.insert(parts, string.format('ЕЩЁ %d', #order - maximumItems)) end return table.concat(parts, ', ') end local function mergeDamageSource(batch, source, totalSamples) if not batch.source then batch.source = source elseif batch.source ~= source then batch.source = 'mixed' end batch.totalSamples = math.max(batch.totalSamples or 0, tonumber(totalSamples) or 0) end local function getSourceNote(source, totalSamples) if source == 'learned' then return string.format(' [ОБУЧЕНО: %d]', tonumber(totalSamples) or 0) end if source == 'rpc' then return ' [RPC — ПРОФИЛЬ ЕЩЁ НЕ ОБУЧЕН]' end if source == 'mixed' then return ' [СМЕШАННЫЕ ДАННЫЕ]' end return '' end local function ensureCombatSession(now) if not combatSession then combatSession = { startedAt = now, lastHitAt = now, outgoingHits = 0, incomingHits = 0, dealt = 0, received = 0, receivedHealth = 0, receivedArmour = 0, receivedWithoutSplit = 0 } end combatSession.lastHitAt = now return combatSession end local function recordOutgoingCombat(now, damage) local session = ensureCombatSession(now) session.outgoingHits = session.outgoingHits + 1 session.dealt = roundToTenth(session.dealt + damage) end local function recordIncomingCombatHit(now) local session = ensureCombatSession(now) session.incomingHits = session.incomingHits + 1 end local function recordIncomingCombatResult(burst, measured, healthLost, armourLost) if not combatSession then return end if measured then combatSession.receivedHealth = roundToTenth(combatSession.receivedHealth + healthLost) combatSession.receivedArmour = roundToTenth(combatSession.receivedArmour + armourLost) combatSession.received = roundToTenth(combatSession.received + healthLost + armourLost) else combatSession.receivedWithoutSplit = roundToTenth( combatSession.receivedWithoutSplit + burst.nominalDamage ) combatSession.received = roundToTenth(combatSession.received + burst.nominalDamage) end end local function emitOutgoingBurst(burst) if not informerEnabled or not chatEnabled or not burst then return end local sourceNote = getSourceNote(burst.source, burst.totalSamples) if burst.hitCount == 1 then addChatMessage(string.format( '[DMG] ВЫ ПОПАЛИ В %s: %s И НАНЕСЛИ УРОНА: %s HP (%s)%s', burst.playerLabel, burst.firstBodyPart, formatDamage(burst.totalDamage), burst.firstWeapon, sourceNote ), 0xFF80FF80) return end addChatMessage(string.format( '[DMG] ВЫ ПОПАЛИ В %s. ПОПАДАНИЙ: %d, УРОН: %s HP (%s; %s)%s', burst.playerLabel, burst.hitCount, formatDamage(burst.totalDamage), formatOrderedCounts(burst.weaponCounts, burst.weaponOrder, 3), formatOrderedCounts(burst.bodyCounts, burst.bodyOrder, 4), sourceNote ), 0xFF80FF80) end local function flushOutgoingBurst(key, emit) local burst = outgoingBursts[key] outgoingBursts[key] = nil if emit then emitOutgoingBurst(burst) end end local function queueOutgoingDamage(playerId, playerLabel, damage, weaponName, bodyPartText, source, totalSamples, now) recordOutgoingCombat(now, damage) if not antiSpamEnabled then emitOutgoingBurst({ playerLabel = playerLabel, hitCount = 1, totalDamage = damage, firstWeapon = weaponName, firstBodyPart = bodyPartText, source = source, totalSamples = totalSamples }) return end local key = tostring(tonumber(playerId) or 65535) local burst = outgoingBursts[key] if burst and (now - burst.lastHitAt >= CHAT_BURST_QUIET_TIME or now - burst.startedAt >= CHAT_BURST_MAX_TIME) then flushOutgoingBurst(key, true) burst = nil end if not burst then burstSequence = burstSequence + 1 burst = { sequence = burstSequence, startedAt = now, lastHitAt = now, playerLabel = playerLabel, hitCount = 0, totalDamage = 0, firstWeapon = weaponName, firstBodyPart = bodyPartText, weaponCounts = {}, weaponOrder = {}, bodyCounts = {}, bodyOrder = {}, totalSamples = 0 } outgoingBursts[key] = burst end burst.lastHitAt = now burst.hitCount = burst.hitCount + 1 burst.totalDamage = roundToTenth(burst.totalDamage + damage) addOrderedCount(burst.weaponCounts, burst.weaponOrder, weaponName) addOrderedCount(burst.bodyCounts, burst.bodyOrder, bodyPartText) mergeDamageSource(burst, source, totalSamples) end local function emitIncomingHit(playerLabel, bodyPartText, weaponName, damage, source, totalSamples) if not informerEnabled or not chatEnabled then return end if type(playerLabel) ~= 'string' or playerLabel == '' then playerLabel = UNKNOWN_PLAYER_LABEL end addChatMessage(string.format( '[DMG] %s ПОПАЛ В ВАС: %s | ОРУЖИЕ: %s | УРОН: ~%s ЕД.%s', playerLabel, bodyPartText, weaponName, formatDamage(damage), getSourceNote(source, totalSamples) ), 0xFFFF8080) end local function finalizeIncomingBurst(_emit, now, refreshVitals) if not incomingBurst then return end now = tonumber(now) or getGameTimer() if refreshVitals ~= false then observeLocalVitals(now) end local burst = incomingBurst incomingBurst = nil local beforeHealth = tonumber(burst.beforeHealth) local beforeArmour = tonumber(burst.beforeArmour) local afterHealth = tonumber(burst.afterHealth) local afterArmour = tonumber(burst.afterArmour) local measured = isFiniteNumber(beforeHealth) and isFiniteNumber(beforeArmour) and isFiniteNumber(afterHealth) and isFiniteNumber(afterArmour) and afterHealth <= beforeHealth + VITAL_EPSILON and afterArmour <= beforeArmour + VITAL_EPSILON local healthLost = 0 local armourLost = 0 if measured then healthLost = roundToTenth(math.max(0, beforeHealth - afterHealth)) armourLost = roundToTenth(math.max(0, beforeArmour - afterArmour)) if healthLost + armourLost <= VITAL_EPSILON and burst.nominalDamage > VITAL_EPSILON then measured = false end end -- Per-hit chat was already emitted synchronously. This delayed tracker is -- intentionally silent and exists only for the measured combat summary. recordIncomingCombatResult(burst, measured, healthLost, armourLost) end local function shouldFinalizeIncoming(now) if not incomingBurst then return false end local age = now - incomingBurst.startedAt local hitQuiet = now - incomingBurst.lastHitAt local vitalQuiet = now - incomingBurst.lastVitalChangeAt return age >= INCOMING_MAX_WAIT or (age >= INCOMING_MIN_SETTLE_TIME and hitQuiet >= CHAT_BURST_QUIET_TIME and vitalQuiet >= INCOMING_VITAL_QUIET_TIME) end local function prepareIncomingForNewHit(now) if incomingBurst and shouldFinalizeIncoming(now) then -- The new hit may already have changed local HP. Do not refresh here, -- otherwise that loss could be charged to both the old and new item. finalizeIncomingBurst(true, now, false) end if incomingBurst then -- A server value from the previous hit must not permanently suppress -- a new local vital change inside the same rapid-fire burst. incomingBurst.serverHealthSeen = false incomingBurst.serverArmourSeen = false end end local function queueIncomingDamage(playerId, playerLabel, damage, weaponName, bodyPartText, source, totalSamples, snapshot, now) local burst = incomingBurst if not burst then burstSequence = burstSequence + 1 burst = { sequence = burstSequence, startedAt = now, lastHitAt = now, lastVitalChangeAt = now, beforeHealth = snapshot and snapshot.beforeHealth or nil, beforeArmour = snapshot and snapshot.beforeArmour or nil, afterHealth = snapshot and snapshot.afterHealth or nil, afterArmour = snapshot and snapshot.afterArmour or nil, localAfterHealth = snapshot and snapshot.localAfterHealth or nil, localAfterArmour = snapshot and snapshot.localAfterArmour or nil, serverHealthSeen = snapshot and snapshot.serverHealthSeen == true or false, serverArmourSeen = snapshot and snapshot.serverArmourSeen == true or false, playerLabel = playerLabel, singlePlayerId = tonumber(playerId), attackerIds = {}, attackerCount = 0, hitCount = 0, nominalDamage = 0, firstWeapon = weaponName, firstBodyPart = bodyPartText, weaponCounts = {}, weaponOrder = {}, bodyCounts = {}, bodyOrder = {}, totalSamples = 0 } incomingBurst = burst end local attackerKey = tostring(tonumber(playerId) or 65535) if not burst.attackerIds[attackerKey] then burst.attackerIds[attackerKey] = true burst.attackerCount = burst.attackerCount + 1 end if burst.attackerCount > 1 or playerLabel == UNKNOWN_PLAYER_LABEL or burst.playerLabel == UNKNOWN_PLAYER_LABEL or playerLabel ~= burst.playerLabel then burst.playerLabel = UNKNOWN_PLAYER_LABEL end burst.lastHitAt = now burst.hitCount = burst.hitCount + 1 burst.nominalDamage = roundToTenth(burst.nominalDamage + damage) addOrderedCount(burst.weaponCounts, burst.weaponOrder, weaponName) addOrderedCount(burst.bodyCounts, burst.bodyOrder, bodyPartText) mergeDamageSource(burst, source, totalSamples) recordIncomingCombatHit(now) end local function updateDamageAggregation(now) local ready = {} for key, burst in pairs(outgoingBursts) do if now - burst.lastHitAt >= CHAT_BURST_QUIET_TIME or now - burst.startedAt >= CHAT_BURST_MAX_TIME then table.insert(ready, { key = key, sequence = burst.sequence }) end end table.sort(ready, function(left, right) return left.sequence < right.sequence end) for _, item in ipairs(ready) do flushOutgoingBurst(item.key, true) end if shouldFinalizeIncoming(now) then finalizeIncomingBurst(true, now) end end local function flushAllDamageMessages(emit, outgoingOnly) local ready = {} for key, burst in pairs(outgoingBursts) do table.insert(ready, { key = key, sequence = burst.sequence }) end table.sort(ready, function(left, right) return left.sequence < right.sequence end) for _, item in ipairs(ready) do flushOutgoingBurst(item.key, emit) end if not outgoingOnly and incomingBurst then finalizeIncomingBurst(emit, getGameTimer()) end end local function clearDamageTracking() outgoingBursts = {} incomingBurst = nil combatSession = nil end local function updateCombatSummary(now) if not combatSession or now - combatSession.lastHitAt < COMBAT_SUMMARY_QUIET_TIME then return end if incomingBurst or next(outgoingBursts) then return end local session = combatSession combatSession = nil if not informerEnabled or not chatEnabled or not combatSummaryEnabled then return end local unsplitNote = '' if session.receivedWithoutSplit > VITAL_EPSILON then unsplitNote = string.format('; БЕЗ РАЗБИВКИ: %s', formatDamage(session.receivedWithoutSplit)) end addChatMessage(string.format( '[БОЙ] НАНЕСЕНО: %s HP (%d попад.); ПОЛУЧЕНО: %s ЕД. (%d попад.; БРОНЯ: %s; HP: %s%s).', formatDamage(session.dealt), session.outgoingHits, formatDamage(session.received), session.incomingHits, formatDamage(session.receivedArmour), formatDamage(session.receivedHealth), unsplitNote ), 0xFFA9C9FF) end local function formatDebugBoolean(value) if value == true then return 'ДА' end if value == false then return 'НЕТ' end return '?' end local function formatDebugNumber(value) if not isFiniteNumber(value) then return '?' end return string.format('%.1f', value) end local function showPrivacyDebug(state) if type(state) ~= 'table' then addChatMessage(string.format( '[DMG DEBUG] Последнего входящего попадания нет. Лимит: %s м, строгий режим: %s.', formatDebugNumber(currentNametagDrawDistance), formatDebugBoolean(strictPrivacyEnabled) ), 0xFFFFD080) return end local result = state.allowed and 'НИК РАЗРЕШЁН' or UNKNOWN_PLAYER_LABEL addChatMessage(string.format( '[DMG DEBUG] ID %s: %s. Причина: %s.', tostring(state.playerId or '?'), result, tostring(state.reason or '?') ), 0xFFFFD080) addChatMessage(string.format( '[DMG DEBUG] Дистанция: %s/%s м; подключён: %s; прогружен: %s.', formatDebugNumber(state.distance), formatDebugNumber(state.drawDistance), formatDebugBoolean(state.connected), formatDebugBoolean(state.streamed) ), 0xFFFFD080) addChatMessage(string.format( '[DMG DEBUG] Ники сервера: %s; флаг игрока: %s; LOS сервера: %s; LOS чиста: %s; строгий: %s.', formatDebugBoolean(state.globalTags), formatDebugBoolean(state.playerTagOverride), formatDebugBoolean(state.nametagLOS), formatDebugBoolean(state.losClear), formatDebugBoolean(state.strict) ), 0xFFFFD080) end local function showDamageMessage(direction, playerId, rpcDamage, weaponId, bodyPartId, incomingSnapshot, now) if not informerEnabled then return end now = tonumber(now) or getGameTimer() local armourStateOverride = nil if direction == 'incoming' and incomingSnapshot and isFiniteNumber(tonumber(incomingSnapshot.beforeArmour)) then armourStateOverride = tonumber(incomingSnapshot.beforeArmour) > VITAL_EPSILON and 'armoured' or 'unarmoured' end local damage, weaponName, source, totalSamples = resolveDamage( direction, playerId, weaponId, bodyPartId, rpcDamage, armourStateOverride ) if direction == 'outgoing' then registerDamageHud(damage, bodyPartId, weaponId, source) if chatEnabled then queueOutgoingDamage( playerId, getPlayerLabel(playerId), damage, weaponName, getBodyPartText(bodyPartId), source, totalSamples, now ) end return end local playerLabel = getIncomingPlayerLabel(playerId) if chatEnabled then local bodyPartText = getBodyPartText(bodyPartId) -- Incoming hit details are security-relevant: emit every shot at once -- so repeated head hits remain visible instead of becoming one burst. emitIncomingHit( playerLabel, bodyPartText, weaponName, damage, source, totalSamples ) queueIncomingDamage( playerId, playerLabel, damage, weaponName, bodyPartText, source, totalSamples, incomingSnapshot, now ) end end -- Fired when this client reports damage dealt to another player. function sampev.onSendGiveDamage(playerId, damage, weaponId, bodyPartId) if not informerEnabled then return end local now = getGameTimer() playerEffects.registerHitNameFlash(playerId, now) showDamageMessage('outgoing', playerId, damage, weaponId, bodyPartId, nil, now) -- Deliberately return nothing: the original RPC passes through unchanged. end -- Fired when this client reports damage received from another player. function sampev.onSendTakeDamage(playerId, damage, weaponId, bodyPartId) if not informerEnabled then return end local now = getGameTimer() prepareIncomingForNewHit(now) local snapshot = captureIncomingSnapshot(now) beginLearningHit(playerId, weaponId, bodyPartId, snapshot, now) showDamageMessage('incoming', playerId, damage, weaponId, bodyPartId, snapshot, now) -- Deliberately return nothing: the original RPC passes through unchanged. end -- Server values are observed for precision but never returned or changed. function sampev.onSetPlayerHealth(health) recordServerVital('health', health) end function sampev.onSetPlayerArmour(armour) recordServerVital('armour', armour) end function sampev.onSetPlayerColor(playerId, color) playerId = tonumber(playerId) local flash = playerId and playerEffects.hitNameFlashes[playerId] or nil local serverColor = playerEffects.normalizeUInt32(color) if flash and serverColor then -- Direct local color-array writes do not pass through SAMP.Lua. -- Preserve the newest real server color and reapply the temporary red -- after the original RPC handler has finished. flash.restoreColor = serverColor flash.reapplyAt = getGameTimer() + 1 end end -- Remote weapon changes come directly from SA-MP synchronization. Two equal -- packets confirm the new slot so a fast or incomplete scroll cannot flicker. function sampev.onPlayerSync(playerId, data) if type(data) == 'table' then playerEffects.observeRemoteOnFootWeapon(playerId, data.weapon, getGameTimer()) end end function sampev.onVehicleSync(playerId, vehicleId, data) playerEffects.seedRemoteWeaponMode( playerId, 'vehicle', type(data) == 'table' and data.currentWeapon or nil ) end function sampev.onPassengerSync(playerId, data) playerEffects.seedRemoteWeaponMode( playerId, 'vehicle', type(data) == 'table' and data.currentWeapon or nil ) end function sampev.onInitGame(playerId, hostName, settings) playerEffects.clearAllPlayerVisualState(true) resetNametagTracking() if type(settings) == 'table' then local serverDistance = tonumber(settings.nametagDrawDist) if isFiniteNumber(serverDistance) and serverDistance >= 0 then currentNametagDrawDistance = serverDistance end if type(settings.showPlayerTags) == 'boolean' then currentServerShowsPlayerTags = settings.showPlayerTags end if type(settings.nametagLOS) == 'boolean' then currentNametagLOS = settings.nametagLOS end end clearDamageTracking() resetVitalTracking() refreshServerIdentity(hostName) playerEffects.reconcileHealthBarSetting(true) end function sampev.onShowPlayerNameTag(playerId, show) playerId = tonumber(playerId) if playerId and playerId >= 0 and playerId <= 1003 then playerNametagShown[playerId] = show == true end end function sampev.onPlayerJoin(playerId) playerId = tonumber(playerId) if playerId then playerNametagShown[playerId] = nil playerEffects.clearPlayerVisualState(playerId, false) end end function sampev.onPlayerQuit(playerId) playerId = tonumber(playerId) if playerId then playerNametagShown[playerId] = nil playerEffects.clearPlayerVisualState(playerId, true) end end function sampev.onPlayerStreamIn(playerId) playerId = tonumber(playerId) if playerId then playerEffects.clearPlayerVisualState(playerId, false) end end function sampev.onPlayerStreamOut(playerId) playerEffects.clearPlayerVisualState(playerId, true) end function sampev.onPlayerDeath(playerId) playerEffects.clearPlayerVisualState(playerId, true) end function sampev.onGamemodeRestart() playerEffects.clearAllPlayerVisualState(true) resetNametagTracking() clearDamageTracking() resetVitalTracking() playerEffects.reconcileHealthBarSetting(true) end local function syncMenuStateFromRuntime() menuState.informer[0] = informerEnabled menuState.chat[0] = chatEnabled menuState.hud[0] = hudEnabled menuState.learning[0] = learningEnabled menuState.antiSpam[0] = antiSpamEnabled menuState.summary[0] = combatSummaryEnabled menuState.privacy[0] = strictPrivacyEnabled menuState.hitNameFlash[0] = hitNameFlashEnabled menuState.playerHealthBar[0] = playerHealthBarEnabled menuState.weaponActionText[0] = weaponActionTextEnabled end local function setInformerOption(enabled, saveNow) enabled = enabled == true if informerEnabled == enabled then return true end informerEnabled = enabled resetVitalTracking() local visualFailed = false if not informerEnabled then clearDamageHud() clearDamageTracking() playerEffects.clearAllPlayerVisualState(true) visualFailed = not playerEffects.restoreOwnedHealthBarPatch() else observeLocalVitals(getGameTimer()) visualFailed = not playerEffects.applyPlayerHealthBarVisibility( playerHealthBarEnabled ) if visualFailed and not playerHealthBarEnabled then -- Keep the saved checkbox truthful when this exact client build -- cannot safely own the bar-only patch. playerHealthBarEnabled = true end end if saveNow == false then return not visualFailed, visualFailed and 'visual_patch' or nil end local saved = saveSettingsData() if visualFailed then return false, 'visual_patch' end return saved end local function setHudOption(enabled, saveNow) enabled = enabled == true if hudEnabled == enabled then return true end hudEnabled = enabled if not hudEnabled then clearDamageHud() end if saveNow == false then return true end return saveSettingsData() end local function setChatOption(enabled, saveNow) enabled = enabled == true if chatEnabled == enabled then return true end chatEnabled = enabled clearDamageTracking() if saveNow == false then return true end return saveSettingsData() end local function setLearningOption(enabled, saveNow) enabled = enabled == true if learningEnabled == enabled then return true end learningEnabled = enabled pendingLearningHit = nil recentLocalVitalWindow = nil recentServerVitalWindow = nil if saveNow == false then return true end return saveSettingsData() end local function setAntiSpamOption(enabled, saveNow) enabled = enabled == true if antiSpamEnabled == enabled then return true end -- Anti-spam controls only outgoing presentation. Keep the hidden incoming -- vital measurement alive so rapid fire is accounted for accurately. flushAllDamageMessages(true, true) antiSpamEnabled = enabled if saveNow == false then return true end return saveSettingsData() end local function setCombatSummaryOption(enabled, saveNow) enabled = enabled == true if combatSummaryEnabled == enabled then return true end flushAllDamageMessages(true) combatSummaryEnabled = enabled combatSession = nil if saveNow == false then return true end return saveSettingsData() end local function setStrictPrivacyOption(enabled, saveNow) enabled = enabled == true if strictPrivacyEnabled == enabled then return true end strictPrivacyEnabled = enabled lastIncomingPrivacySnapshot = nil menuPrivacyState = nil if saveNow == false then return true end return saveSettingsData() end local function setHitNameFlashOption(enabled, saveNow) enabled = enabled == true if hitNameFlashEnabled == enabled then return true end hitNameFlashEnabled = enabled if not hitNameFlashEnabled then playerEffects.restoreAllHitNameFlashes() end if saveNow == false then return true end return saveSettingsData() end local function setPlayerHealthBarOption(enabled, saveNow) enabled = enabled == true if playerHealthBarEnabled == enabled then return true end if informerEnabled and not playerEffects.applyPlayerHealthBarVisibility(enabled) then return false, 'visual_patch' end playerHealthBarEnabled = enabled if saveNow == false then return true end return saveSettingsData() end local function setWeaponActionTextOption(enabled, saveNow) enabled = enabled == true if weaponActionTextEnabled == enabled then return true end weaponActionTextEnabled = enabled if not weaponActionTextEnabled then playerEffects.weaponActionNotices = {} end if saveNow == false then return true end return saveSettingsData() end local function setMenuStatus(text, good) menuStatusText = tostring(text or '') menuStatusGood = good ~= false menuStatusUntil = getGameTimer() + 5000 end local function applyMenuOption(pointer, setter, title) local saved, reason = setter(pointer[0] == true) syncMenuStateFromRuntime() if saved then setMenuStatus(title .. ': настройка сохранена.', true) elseif reason == 'visual_patch' then setMenuStatus(playerEffects.healthBarPatchStatus, false) else setMenuStatus('Не удалось сохранить настройку на диск.', false) end end local function enableAllMenuOptions() local allReady = true local function apply(setter) local ok = setter(true, false) allReady = allReady and ok == true end apply(setInformerOption) apply(setHudOption) apply(setChatOption) apply(setLearningOption) apply(setAntiSpamOption) apply(setCombatSummaryOption) apply(setStrictPrivacyOption) apply(setHitNameFlashOption) local healthBarReady = setPlayerHealthBarOption(true, false) allReady = allReady and healthBarReady == true apply(setWeaponActionTextOption) local saved = saveSettingsData() syncMenuStateFromRuntime() setMenuStatus( saved and allReady and 'Все функции включены.' or (not allReady and playerEffects.healthBarPatchStatus or 'Функции включены, но настройки не сохранены.'), saved and allReady ) end local function showLearningStatsInChat() local combinations, ready, samples = getLearningStats() local mode = currentServerIsGambit and 'Gambit: таблица + автообучение' or 'универсальное автообучение' addChatMessage(string.format( '[Damage Informer] Сервер %s. Режим: %s.', currentServerKey, mode ), 0xFFA9C9FF) addChatMessage(string.format( '[Damage Informer] Комбинаций: %d, готовы: %d, чистых замеров: %d (нужно %d).', combinations, ready, samples, LEARNING_MIN_SAMPLES ), 0xFFA9C9FF) addChatMessage(string.format( '[Damage Informer] Антиспам исходящих: %s; итог боя: %s; строгая приватность: %s; лимит ника: %s м.', antiSpamEnabled and 'ВКЛ' or 'ВЫКЛ', combatSummaryEnabled and 'ВКЛ' or 'ВЫКЛ', strictPrivacyEnabled and 'ВКЛ' or 'ВЫКЛ', formatDebugNumber(currentNametagDrawDistance) ), 0xFFA9C9FF) end local function openOrCloseMenu() if menuOpen[0] and menuResetPopupOpen then return end menuOpen[0] = not menuOpen[0] if menuOpen[0] then syncMenuStateFromRuntime() menuStatusText = '' end end local function protectedBooleanCall(callback) if type(callback) ~= 'function' then return false end local ok, result = pcall(callback) return ok and result == true end local function canOpenMenuFromHotkey() return not protectedBooleanCall(sampIsChatInputActive) and not protectedBooleanCall(sampIsDialogActive) and not protectedBooleanCall(sampIsScoreboardOpen) and not protectedBooleanCall(isPauseMenuActive) end local MENU_ACCENT = imgui.ImVec4(0.30, 0.69, 1.00, 1.00) local MENU_GOOD = imgui.ImVec4(0.38, 0.88, 0.55, 1.00) local MENU_BAD = imgui.ImVec4(1.00, 0.38, 0.38, 1.00) local MENU_WARNING = imgui.ImVec4(1.00, 0.73, 0.30, 1.00) local MENU_MUTED = imgui.ImVec4(0.64, 0.69, 0.76, 1.00) local menuUiScale = 1 local function menuSize(value) return math.floor(value * menuUiScale + 0.5) end local function drawWrappedText(text) imgui.PushTextWrapPos(0) imgui.TextUnformatted(tostring(text or '')) imgui.PopTextWrapPos() end local function drawWrappedMuted(text) imgui.PushStyleColor(imgui.Col.Text, MENU_MUTED) drawWrappedText(text) imgui.PopStyleColor() end local function drawOptionCard(id, title, description, pointer, setter, height) imgui.BeginChild('option_' .. id, imgui.ImVec2(0, menuSize(height or 78)), true) if imgui.Checkbox(title .. '##' .. id, pointer) then applyMenuOption(pointer, setter, title) end imgui.Spacing() drawWrappedMuted(description) imgui.EndChild() end local function drawDisplayOptions() imgui.TextColored(MENU_ACCENT, '%s', 'ОТОБРАЖЕНИЕ') imgui.Spacing() drawOptionCard( 'hud', 'HUD: хитмаркер и цифры', 'Показывает подтверждение попадания и всплывающий урон у прицела.', menuState.hud, setHudOption ) imgui.Spacing() drawOptionCard( 'chat', 'Сообщения в чате', 'Пишет нанесённый и полученный урон в игровой чат.', menuState.chat, setChatOption ) imgui.Spacing() drawOptionCard( 'summary', 'Итог боя', 'После 5 секунд тишины показывает общий нанесённый и полученный урон.', menuState.summary, setCombatSummaryOption ) end local function drawProcessingOptions() imgui.TextColored(MENU_ACCENT, '%s', 'ОБРАБОТКА И БЕЗОПАСНОСТЬ') imgui.Spacing() drawOptionCard( 'learning', 'Автообучение урону', 'Собирает чистые замеры отдельно для каждого сервера и типа попадания.', menuState.learning, setLearningOption ) imgui.Spacing() drawOptionCard( 'antispam', 'Антиспам исходящих серий', 'Объединяет только исходящие серии. Входящие всегда выводятся сразу по одному.', menuState.antiSpam, setAntiSpamOption ) imgui.Spacing() drawOptionCard( 'privacy', 'Строгая приватность ника', 'Дополнительно учитывает серверные флаги и LOS. Дистанция проверяется всегда.', menuState.privacy, setStrictPrivacyOption ) end local function drawSettingsTab() drawOptionCard( 'master', 'Главный информер', informerEnabled and 'Обработка попаданий активна. Ниже можно независимо настроить каждый модуль.' or 'Весь вывод остановлен. Остальные параметры можно подготовить заранее.', menuState.informer, setInformerOption, 84 ) imgui.Spacing() local available = imgui.GetContentRegionAvail() if available.x >= menuSize(620) then local columnWidth = (available.x - menuSize(10)) / 2 local columnHeight = menuSize(310) imgui.BeginChild('display_options', imgui.ImVec2(columnWidth, columnHeight), false) drawDisplayOptions() imgui.EndChild() imgui.SameLine() imgui.BeginChild('processing_options', imgui.ImVec2(0, columnHeight), false) drawProcessingOptions() imgui.EndChild() else drawDisplayOptions() imgui.Spacing() imgui.Separator() imgui.Spacing() drawProcessingOptions() end imgui.Spacing() local actionWidth = imgui.GetContentRegionAvail().x local sideBySide = actionWidth >= menuSize(460) local buttonWidth = sideBySide and (actionWidth - menuSize(8)) / 2 or actionWidth if imgui.Button('Включить все функции', imgui.ImVec2(buttonWidth, menuSize(36))) then enableAllMenuOptions() end if sideBySide then imgui.SameLine() else imgui.Spacing() end if imgui.Button('Отключить информер целиком', imgui.ImVec2(buttonWidth, menuSize(36))) then local saved, reason = setInformerOption(false) syncMenuStateFromRuntime() setMenuStatus( saved and 'Информер полностью отключён.' or (reason == 'visual_patch' and playerEffects.healthBarPatchStatus or 'Информер отключён, но настройка не сохранена.'), saved ) end end local function drawPlayerEffectsTab() imgui.TextColored(MENU_ACCENT, '%s', 'ЭФФЕКТЫ НАД ИГРОКАМИ') drawWrappedMuted( 'Каждый эффект включается отдельно. Главный информер временно останавливает их все, ' .. 'не меняя выбранные здесь настройки.' ) imgui.Spacing() drawOptionCard( 'hit_name_flash', 'Красный ник после попадания', 'После вашего попадания ник цели краснеет примерно на 0,6 секунды, затем возвращает серверный цвет.', menuState.hitNameFlash, setHitNameFlashOption, 92 ) imgui.Spacing() drawOptionCard( 'player_health_bar', 'Полоска HP/брони над игроками', 'Снимите галочку, чтобы скрыть только стандартные полоски HP и брони. Сам никнейм останется видимым.', menuState.playerHealthBar, setPlayerHealthBarOption, 92 ) imgui.Spacing() drawOptionCard( 'weapon_action_text', 'RP-текст о доставании оружия', 'У игроков поблизости (до 20 м и только в зоне стандартного ника) показывает: «* достаёт оружие Deagle *».', menuState.weaponActionText, setWeaponActionTextOption, 92 ) imgui.Spacing() imgui.BeginChild('health_bar_patch_status', imgui.ImVec2(0, menuSize(62)), true) imgui.TextColored(MENU_ACCENT, '%s', 'СОСТОЯНИЕ ПОЛОСКИ') drawWrappedMuted( playerEffects.healthBarPatchStatus ~= '' and playerEffects.healthBarPatchStatus or 'Безопасная проверка samp.dll будет выполнена при запуске.' ) imgui.EndChild() end local function drawMetricCard(id, title, value, width) imgui.BeginChild('metric_' .. id, imgui.ImVec2(width, menuSize(72)), true) imgui.TextColored(MENU_ACCENT, '%s', tostring(value)) drawWrappedMuted(title) imgui.EndChild() end local function drawPrivacyStateInMenu(state) if type(state) ~= 'table' then drawWrappedMuted('Выберите ID или откройте результат последнего входящего попадания.') return end local result = state.allowed and 'НИК РАЗРЕШЁН' or UNKNOWN_PLAYER_LABEL imgui.TextColored(state.allowed and MENU_GOOD or MENU_WARNING, '%s', result) drawWrappedText(string.format( 'ID: %s | причина: %s', tostring(state.playerId or '?'), tostring(state.reason or '?') )) drawWrappedText(string.format( 'Дистанция: %s / %s м | подключён: %s | прогружен: %s', formatDebugNumber(state.distance), formatDebugNumber(state.drawDistance), formatDebugBoolean(state.connected), formatDebugBoolean(state.streamed) )) drawWrappedText(string.format( 'Ники: %s | флаг игрока: %s | LOS: %s | строгий режим: %s', formatDebugBoolean(state.globalTags), formatDebugBoolean(state.playerTagOverride), formatDebugBoolean(state.losClear), formatDebugBoolean(state.strict) )) end local function drawStatusTab() local contentWidth = imgui.GetContentRegionAvail().x local narrow = contentWidth < menuSize(560) imgui.BeginChild('server_status', imgui.ImVec2(0, menuSize(narrow and 138 or 105)), true) imgui.TextColored(MENU_ACCENT, '%s', 'ТЕКУЩИЙ СЕРВЕР') local displayName = 'Неизвестно' if currentServerName ~= '' then local okName, utf8Name = pcall(function() return u8(currentServerName) end) displayName = okName and utf8Name or currentServerName end drawWrappedText('Название: ' .. displayName) drawWrappedText('Адрес: ' .. tostring(currentServerKey)) drawWrappedText(string.format( 'Режим: %s | дистанция ников: %s м', currentServerIsGambit and 'Gambit: таблица + автообучение' or 'универсальное автообучение', formatDebugNumber(currentNametagDrawDistance) )) imgui.EndChild() imgui.Spacing() local combinations, ready, samples = getLearningStats() local metricAvailable = imgui.GetContentRegionAvail().x if metricAvailable >= menuSize(500) then local metricWidth = (metricAvailable - menuSize(16)) / 3 drawMetricCard('combinations', 'Комбинаций', combinations, metricWidth) imgui.SameLine() drawMetricCard('ready', 'Готовы к использованию', ready, metricWidth) imgui.SameLine() drawMetricCard('samples', 'Чистых замеров', samples, 0) else drawMetricCard('combinations', 'Комбинаций', combinations, 0) imgui.Spacing() drawMetricCard('ready', 'Готовы к использованию', ready, 0) imgui.Spacing() drawMetricCard('samples', 'Чистых замеров', samples, 0) end local readyFraction = combinations > 0 and ready / combinations or 0 imgui.ProgressBar( clamp(readyFraction, 0, 1), imgui.ImVec2(-1, menuSize(20)), string.format('Готовность профиля: %d из %d', ready, combinations) ) imgui.Spacing() imgui.Separator() imgui.TextColored(MENU_ACCENT, '%s', 'ДИАГНОСТИКА ПРИВАТНОСТИ') drawWrappedMuted('Проверяет, будет ли ник показан при входящем уроне прямо сейчас.') imgui.TextUnformatted('ID игрока') imgui.SetNextItemWidth(narrow and -1 or menuSize(150)) if imgui.InputInt('##privacy_id', menuPlayerId, 1, 10) then menuPlayerId[0] = clamp(menuPlayerId[0], 0, 1003) end if narrow then local diagnosticAvailable = imgui.GetContentRegionAvail().x local diagnosticSideBySide = diagnosticAvailable >= menuSize(360) local diagnosticButtonWidth = diagnosticSideBySide and (diagnosticAvailable - menuSize(8)) / 2 or diagnosticAvailable if imgui.Button('Проверить ID', imgui.ImVec2(diagnosticButtonWidth, 0)) then menuPrivacyState = inspectIncomingPlayer(menuPlayerId[0]) end if diagnosticSideBySide then imgui.SameLine() else imgui.Spacing() end if imgui.Button('Последнее попадание', imgui.ImVec2(diagnosticButtonWidth, 0)) then menuPrivacyState = lastIncomingPrivacySnapshot end else imgui.SameLine() if imgui.Button('Проверить ID', imgui.ImVec2(menuSize(130), 0)) then menuPrivacyState = inspectIncomingPlayer(menuPlayerId[0]) end imgui.SameLine() if imgui.Button('Последнее попадание', imgui.ImVec2(menuSize(170), 0)) then menuPrivacyState = lastIncomingPrivacySnapshot end end imgui.BeginChild('privacy_result', imgui.ImVec2(0, menuSize(narrow and 155 or 104)), true) drawPrivacyStateInMenu(menuPrivacyState) imgui.EndChild() imgui.Spacing() local actionAvailable = imgui.GetContentRegionAvail().x local actionsSideBySide = actionAvailable >= menuSize(510) local statusActionWidth = actionsSideBySide and (actionAvailable - menuSize(8)) / 2 or actionAvailable if imgui.Button('Показать статистику в чате', imgui.ImVec2( statusActionWidth, menuSize(32) )) then showLearningStatsInChat() setMenuStatus('Статистика выведена в игровой чат.', true) end if actionsSideBySide then imgui.SameLine() else imgui.Spacing() end imgui.PushStyleColor(imgui.Col.Button, imgui.ImVec4(0.55, 0.16, 0.18, 1.00)) imgui.PushStyleColor(imgui.Col.ButtonHovered, imgui.ImVec4(0.72, 0.20, 0.22, 1.00)) imgui.PushStyleColor(imgui.Col.ButtonActive, imgui.ImVec4(0.43, 0.11, 0.13, 1.00)) if imgui.Button('Сбросить обучение сервера', imgui.ImVec2( statusActionWidth, menuSize(32) )) then menuStatusText = '' menuResetPopupOpen = true imgui.OpenPopup('Подтверждение сброса##learning_reset') end imgui.PopStyleColor(3) if imgui.BeginPopupModal( 'Подтверждение сброса##learning_reset', nil, imgui.WindowFlags.AlwaysAutoResize ) then imgui.TextColored(MENU_WARNING, '%s', 'Это действие удалит обученные значения только текущего сервера.') imgui.TextUnformatted('Сервер: ' .. tostring(currentServerKey)) imgui.TextUnformatted(string.format( 'Будет удалено: комбинаций %d, замеров %d.', combinations, samples )) imgui.Spacing() if imgui.Button('Отмена', imgui.ImVec2(menuSize(150), menuSize(32))) then menuResetPopupOpen = false imgui.CloseCurrentPopup() end imgui.SameLine() imgui.PushStyleColor(imgui.Col.Button, imgui.ImVec4(0.55, 0.16, 0.18, 1.00)) if imgui.Button('Удалить данные', imgui.ImVec2(menuSize(170), menuSize(32))) then local ok, removedCombinations, removedSamples, reason = resetCurrentServerLearning() if ok then menuResetPopupOpen = false setMenuStatus(string.format( 'Удалено: комбинаций %d, замеров %d.', removedCombinations, removedSamples ), true) imgui.CloseCurrentPopup() elseif reason == 'save' then setMenuStatus('Сброс не сохранён; исходные данные восстановлены.', false) else setMenuStatus('Сервер ещё не определён; сброс отменён.', false) end end imgui.PopStyleColor() if not menuStatusGood and menuStatusText ~= '' then imgui.Spacing() imgui.TextColored(MENU_BAD, '%s', menuStatusText) end imgui.EndPopup() end end local function drawAboutTab() imgui.TextColored(MENU_ACCENT, '%s', 'GAMBIT DAMAGE INFORMER v1.5.3') imgui.TextUnformatted('Автор скрипта: bro from sw') imgui.Spacing() imgui.PushStyleColor(imgui.Col.ChildBg, imgui.ImVec4(0.22, 0.11, 0.06, 0.72)) imgui.PushStyleColor(imgui.Col.Border, imgui.ImVec4(0.92, 0.50, 0.16, 0.85)) imgui.BeginChild('official_source_warning', imgui.ImVec2(0, menuSize(142)), true) imgui.TextColored(MENU_WARNING, '%s', 'ВАЖНО: БЕЗОПАСНЫЙ ИСТОЧНИК') imgui.Spacing() drawWrappedText( 'Данный скрипт можно скачивать исключительно из раздела «Модификации» ' .. 'на форуме Gambit RP. Копии с других сайтов, из чужих сборок и ' .. 'прочих источников могут содержать вирусы или иной вредоносный код.' ) imgui.EndChild() imgui.PopStyleColor(2) imgui.Spacing() imgui.TextColored(MENU_ACCENT, '%s', 'УПРАВЛЕНИЕ') drawWrappedText('/dmgmenu или F10 — открыть и закрыть это меню') drawWrappedText('/dmginfo — весь информер | /dmghud — HUD | /dmgchat — чат') drawWrappedText('/dmglearn — обучение | /dmgspam — антиспам | /dmgsummary — итог боя') drawWrappedText('/dmgprivacy — приватность | /dmgdebug [ID] — диагностика') drawWrappedText('/dmgstats — статистика | /dmgreset confirm — сброс обучения') drawWrappedText('/dmghelp — список всех команд в чате') imgui.Spacing() drawWrappedMuted( 'Сетевые события урона и значения HP/брони только читаются. Эффекты ' .. 'меняют исключительно локальное отображение клиента; сетевые пакеты ' .. 'не блокируются, не изменяются и не пересылаются.' ) end imgui.OnInitialize(function() local style = imgui.GetStyle() style.WindowRounding = 10 style.ChildRounding = 7 style.FrameRounding = 6 style.PopupRounding = 8 style.ScrollbarRounding = 8 style.GrabRounding = 6 style.Colors[imgui.Col.WindowBg] = imgui.ImVec4(0.055, 0.070, 0.095, 0.98) style.Colors[imgui.Col.ChildBg] = imgui.ImVec4(0.075, 0.095, 0.125, 0.92) style.Colors[imgui.Col.PopupBg] = imgui.ImVec4(0.065, 0.080, 0.105, 0.99) style.Colors[imgui.Col.Border] = imgui.ImVec4(0.20, 0.29, 0.39, 0.85) style.Colors[imgui.Col.FrameBg] = imgui.ImVec4(0.11, 0.15, 0.20, 1.00) style.Colors[imgui.Col.FrameBgHovered] = imgui.ImVec4(0.15, 0.23, 0.31, 1.00) style.Colors[imgui.Col.FrameBgActive] = imgui.ImVec4(0.18, 0.29, 0.39, 1.00) style.Colors[imgui.Col.Button] = imgui.ImVec4(0.10, 0.27, 0.42, 1.00) style.Colors[imgui.Col.ButtonHovered] = imgui.ImVec4(0.13, 0.39, 0.61, 1.00) style.Colors[imgui.Col.ButtonActive] = imgui.ImVec4(0.08, 0.22, 0.35, 1.00) style.Colors[imgui.Col.Header] = imgui.ImVec4(0.10, 0.29, 0.46, 1.00) style.Colors[imgui.Col.HeaderHovered] = imgui.ImVec4(0.13, 0.40, 0.63, 1.00) style.Colors[imgui.Col.HeaderActive] = imgui.ImVec4(0.09, 0.33, 0.52, 1.00) style.Colors[imgui.Col.CheckMark] = MENU_ACCENT style.Colors[imgui.Col.Tab] = imgui.ImVec4(0.08, 0.16, 0.24, 1.00) style.Colors[imgui.Col.TabHovered] = imgui.ImVec4(0.12, 0.39, 0.61, 1.00) style.Colors[imgui.Col.TabActive] = imgui.ImVec4(0.10, 0.30, 0.48, 1.00) style.Colors[imgui.Col.TitleBg] = imgui.ImVec4(0.04, 0.08, 0.12, 1.00) style.Colors[imgui.Col.TitleBgActive] = imgui.ImVec4(0.07, 0.19, 0.30, 1.00) end) local menuFrame = imgui.OnFrame(function() return menuOpen[0] and not protectedBooleanCall(isPauseMenuActive) end, function() local screenWidth, screenHeight = getScreenResolution() local okScale, dpiScale = pcall(imgui.GetDpiScale) if okScale and isFiniteNumber(dpiScale) and dpiScale > 0 then menuUiScale = clamp(dpiScale, 0.75, 3.00) else menuUiScale = 1 end local margin = menuSize(24) local maximumWidth = math.max(300, screenWidth - margin * 2) local maximumHeight = math.max(300, screenHeight - margin * 2) local desiredWidth = math.min(menuSize(760), maximumWidth) local desiredHeight = math.min(menuSize(610), maximumHeight) local minimumWidth = math.min(menuSize(690), maximumWidth) local minimumHeight = math.min(menuSize(560), maximumHeight) imgui.SetNextWindowPos( imgui.ImVec2(screenWidth / 2, screenHeight / 2), imgui.Cond.FirstUseEver, imgui.ImVec2(0.5, 0.5) ) imgui.SetNextWindowSize( imgui.ImVec2(desiredWidth, desiredHeight), imgui.Cond.FirstUseEver ) imgui.SetNextWindowSizeConstraints( imgui.ImVec2(minimumWidth, minimumHeight), imgui.ImVec2(maximumWidth, maximumHeight) ) local visible = imgui.Begin( 'Gambit Damage Informer v1.5.3##damage_informer_menu', menuOpen, imgui.WindowFlags.NoCollapse ) if visible then local masterColor = informerEnabled and MENU_GOOD or MENU_BAD imgui.TextColored( masterColor, '%s', informerEnabled and 'ИНФОРМЕР ВКЛЮЧЁН' or 'ИНФОРМЕР ВЫКЛЮЧЕН' ) imgui.SameLine() imgui.TextDisabled('| F10 — закрыть меню') imgui.Separator() if imgui.BeginTabBar('damage_informer_tabs') then if imgui.BeginTabItem('Настройки') then drawSettingsTab() imgui.EndTabItem() end if imgui.BeginTabItem('Эффекты игроков') then drawPlayerEffectsTab() imgui.EndTabItem() end if imgui.BeginTabItem('Статус и диагностика') then drawStatusTab() imgui.EndTabItem() end if imgui.BeginTabItem('О скрипте') then drawAboutTab() imgui.EndTabItem() end imgui.EndTabBar() end imgui.Separator() if menuStatusText ~= '' and getGameTimer() <= menuStatusUntil then imgui.TextColored(menuStatusGood and MENU_GOOD or MENU_BAD, '%s', menuStatusText) else imgui.TextDisabled('Изменения сохраняются автоматически.') end imgui.TextDisabled('Автор: bro from sw | Оригинал: раздел «Модификации» форума Gambit RP') end imgui.End() end) menuFrame.LockPlayer = true function main() repeat wait(100) until isSampAvailable() loadSettingsData() loadLearningData() refreshServerIdentity() observeLocalVitals(getGameTimer()) playerEffects.reconcileHealthBarSetting(true) syncMenuStateFromRuntime() local _, screenHeight = getScreenResolution() local fontHeight = math.max(11, math.floor(15 * clamp(screenHeight / 1080, 0.75, 1.50) + 0.5)) -- Font flags 1 + 4: bold text with an outline. damageFont = renderCreateFont('Arial', fontHeight, 5) local worldFontHeight = math.max( 10, math.floor(13 * clamp(screenHeight / 1080, 0.75, 1.50) + 0.5) ) playerEffects.worldActionFont = renderCreateFont('Arial', worldFontHeight, 5) sampRegisterChatCommand('dmgmenu', function() openOrCloseMenu() end) sampRegisterChatCommand('dmginfo', function() local _, reason = setInformerOption(not informerEnabled) syncMenuStateFromRuntime() local state = informerEnabled and 'ВКЛЮЧЁН' or 'ВЫКЛЮЧЕН' local color = informerEnabled and 0xFF80FF80 or 0xFFFF8080 addChatMessage('[Damage Informer] Информер ' .. state .. '. Команда: /dmginfo', color) if reason == 'visual_patch' then addChatMessage( '[Damage Informer] ' .. playerEffects.healthBarPatchStatus, 0xFFFF8080 ) end end) sampRegisterChatCommand('dmghud', function() setHudOption(not hudEnabled) syncMenuStateFromRuntime() local state = hudEnabled and 'ВКЛЮЧЁН' or 'ВЫКЛЮЧЕН' local color = hudEnabled and 0xFF80FF80 or 0xFFFF8080 addChatMessage('[Damage Informer] Хитмаркер и цифры урона: ' .. state .. '. Команда: /dmghud', color) end) sampRegisterChatCommand('dmgchat', function() setChatOption(not chatEnabled) syncMenuStateFromRuntime() local state = chatEnabled and 'ВКЛЮЧЁН' or 'ВЫКЛЮЧЕН' local color = chatEnabled and 0xFF80FF80 or 0xFFFF8080 addChatMessage('[Damage Informer] Сообщения об уроне в чате: ' .. state .. '. Команда: /dmgchat', color) end) sampRegisterChatCommand('dmglearn', function() setLearningOption(not learningEnabled) syncMenuStateFromRuntime() local state = learningEnabled and 'ВКЛЮЧЕНО' or 'ВЫКЛЮЧЕНО' local color = learningEnabled and 0xFF80FF80 or 0xFFFF8080 addChatMessage('[Damage Informer] Автообучение: ' .. state .. '. Команда: /dmglearn', color) end) sampRegisterChatCommand('dmgstats', function() showLearningStatsInChat() end) sampRegisterChatCommand('dmgspam', function() setAntiSpamOption(not antiSpamEnabled) syncMenuStateFromRuntime() local state = antiSpamEnabled and 'ВКЛЮЧЁН' or 'ВЫКЛЮЧЕН' local color = antiSpamEnabled and 0xFF80FF80 or 0xFFFF8080 addChatMessage('[Damage Informer] Антиспам исходящих очередей ' .. state .. '. Команда: /dmgspam', color) end) sampRegisterChatCommand('dmgsummary', function() setCombatSummaryOption(not combatSummaryEnabled) syncMenuStateFromRuntime() local state = combatSummaryEnabled and 'ВКЛЮЧЁН' or 'ВЫКЛЮЧЕН' local color = combatSummaryEnabled and 0xFF80FF80 or 0xFFFF8080 addChatMessage('[Damage Informer] Итог боя ' .. state .. '. Команда: /dmgsummary', color) end) sampRegisterChatCommand('dmgprivacy', function() setStrictPrivacyOption(not strictPrivacyEnabled) syncMenuStateFromRuntime() local state = strictPrivacyEnabled and 'ВКЛЮЧЁН' or 'ВЫКЛЮЧЕН' local color = strictPrivacyEnabled and 0xFF80FF80 or 0xFFFF8080 addChatMessage('[Damage Informer] Строгий режим приватности ' .. state .. '. Команда: /dmgprivacy', color) addChatMessage('[Damage Informer] Дистанция никнейма и прогрузка игрока проверяются всегда.', 0xFFFFD080) end) sampRegisterChatCommand('dmgdebug', function(params) params = type(params) == 'string' and params:match('^%s*(.-)%s*$') or '' if params ~= '' then local playerId = tonumber(params) if not playerId then addChatMessage('[DMG DEBUG] Формат: /dmgdebug [ID]', 0xFFFF8080) return end showPrivacyDebug(inspectIncomingPlayer(playerId)) return end showPrivacyDebug(lastIncomingPrivacySnapshot) end) sampRegisterChatCommand('dmgreset', function(params) params = type(params) == 'string' and params:lower():match('^%s*(.-)%s*$') or '' if params ~= 'confirm' then addChatMessage('[Damage Informer] Сброс удалит автообучение только текущего сервера.', 0xFFFFB070) addChatMessage('[Damage Informer] Для подтверждения: /dmgreset confirm', 0xFFFFB070) return end local ok, combinations, samples, reason = resetCurrentServerLearning() if not ok then if reason == 'save' then addChatMessage('[Damage Informer] Не удалось сохранить сброс. Данные восстановлены.', 0xFFFF8080) else addChatMessage('[Damage Informer] Сервер ещё не определён: сброс отменён.', 0xFFFF8080) end return end addChatMessage(string.format( '[Damage Informer] Автообучение сброшено: комбинаций %d, замеров %d.', combinations, samples ), 0xFF80FF80) end) sampRegisterChatCommand('dmghelp', function() addChatMessage('[Damage Informer] /dmgmenu (или F10), /dmginfo, /dmghud, /dmgchat', 0xFFA9C9FF) addChatMessage('[Damage Informer] /dmglearn, /dmgstats, /dmgspam, /dmgsummary', 0xFFA9C9FF) addChatMessage('[Damage Informer] /dmgprivacy, /dmgdebug [ID]', 0xFFA9C9FF) addChatMessage('[Damage Informer] /dmgreset confirm — сброс автообучения текущего сервера', 0xFFA9C9FF) end) addChatMessage('[Damage Informer] Загружен v1.5.3. Автор скрипта: bro from sw.', 0xFFA9C9FF) addChatMessage('[Damage Informer] Скачивайте скрипт только из раздела «Модификации» на форуме Gambit RP.', 0xFFFFD080) addChatMessage('[Damage Informer] Другие сайты, сборки и источники могут содержать вирусы или вредоносный код.', 0xFFFF8080) addChatMessage('[Damage Informer] Меню: /dmgmenu или F10. Все команды: /dmghelp', 0xFFA9C9FF) addChatMessage('[Damage Informer] Профиль сервера: ' .. currentServerKey, 0xFFA9C9FF) while true do wait(0) if wasKeyPressed(vkeys.VK_F10) and (menuOpen[0] or canOpenMenuFromHotkey()) then openOrCloseMenu() end updateLearning() local now = getGameTimer() playerEffects.updateHitNameFlashes(now) updateDamageAggregation(now) updateCombatSummary(now) drawDamageHud() playerEffects.drawWeaponActionNotices(now) end end function onScriptTerminate(script, quitGame) if script == thisScript() then menuOpen[0] = false clearDamageTracking() playerEffects.clearAllPlayerVisualState(true) playerEffects.restoreOwnedHealthBarPatch() saveSettingsData() saveLearningData(true) if damageFont then renderReleaseFont(damageFont) damageFont = nil end if playerEffects.worldActionFont then renderReleaseFont(playerEffects.worldActionFont) playerEffects.worldActionFont = nil end end end