script_name('MyPame Manager') script_author('marcumypka (ну и нейронка да и что') script_version('1.3') require 'lib.moonloader' local encoding = require 'encoding' encoding.default = 'CP1251' -- поменяй, если у тебя другая кодировка игры local u8 = encoding.UTF8 local function T(s) return u8:decode(s) end local sampev = require 'lib.samp.events' local ffi = require 'ffi' -- mimgui нужен только для всплывающего окошка-списка заготовок под настоящим -- диалогом сервера (ручной режим, без /mypame <номер>). Если библиотеки нет — -- скрипт продолжает работать как раньше, просто без этого окошка. local imgui_ok, imgui = pcall(require, 'mimgui') ffi.cdef[[ void* GetActiveWindow(); int PostMessageA(void* hWnd, unsigned int Msg, uintptr_t wParam, intptr_t lParam); ]] local WM_CHAR = 0x0102 local WM_KEYDOWN = 0x0100 local WM_KEYUP = 0x0101 local VK_BACK = 0x08 local VK_RETURN = 0x0D local function simulateType(s, delayMs) local hwnd = ffi.C.GetActiveWindow() if hwnd == nil then return false end if delayMs and delayMs > 0 then for i = 1, #s do ffi.C.PostMessageA(hwnd, WM_CHAR, s:byte(i), 0) wait(delayMs) end else for i = 1, #s do ffi.C.PostMessageA(hwnd, WM_CHAR, s:byte(i), 0) end wait(0) end return true end local function simulateBackspaceAll(maxChars, delayMs) local hwnd = ffi.C.GetActiveWindow() if hwnd == nil then return end maxChars = maxChars or 130 if delayMs and delayMs > 0 then for _ = 1, maxChars do ffi.C.PostMessageA(hwnd, WM_KEYDOWN, VK_BACK, 0) ffi.C.PostMessageA(hwnd, WM_KEYUP, VK_BACK, 0) wait(delayMs) end else for _ = 1, maxChars do ffi.C.PostMessageA(hwnd, WM_KEYDOWN, VK_BACK, 0) ffi.C.PostMessageA(hwnd, WM_KEYUP, VK_BACK, 0) end wait(0) end end local function simulateSubmit() local hwnd = ffi.C.GetActiveWindow() if hwnd == nil then return false end ffi.C.PostMessageA(hwnd, WM_KEYDOWN, VK_RETURN, 0) ffi.C.PostMessageA(hwnd, WM_KEYUP, VK_RETURN, 0) return true end ---------------------------------------------------------------- -- CONFIG ---------------------------------------------------------------- local CONFIG = { command = 'mypame', real_command = '/mypame', marker_view = 'Посмотреть', -- Общий префикс среднего пункта меню сервера. Он одинаковый и когда поле -- называется "описание", и когда "слухи" (и для любых других вариантов -- названия этого поля, если сервер их добавит) — сам текст после -- двоеточия у разных объектов разный, а "Режим:" — всегда одно. -- (раньше сервер писал "Редактировать:" — если у вас старая версия -- сервера, верните здесь 'Редактировать:') marker_desc = 'Режим:', marker_line1 = 'Изменить строку 1', marker_line2 = 'Изменить строку 2', marker_line3 = 'Изменить строку 3', -- Пункт меню, который открывает ЛОКАЛЬНЫЙ список наших заготовок вместо -- того, что бы ни делал этот пункт на сервере (см. presets_hijack_enabled). marker_presets = 'Пресеты', marker_clear = 'Очистить', -- Что класть в буфер обмена: 'raw' (значение как есть, в CP1251 — по -- умолчанию) или 'utf8' (перекодировать CP1251 -> UTF-8 перед копированием). -- Используется только командой /mypame testclip для диагностики. clipboard_mode = 'raw', -- Пауза между символами при эмуляции печати (мс). 0 (по умолчанию) — -- "пакетный" режим, максимально быстро (см. комментарий у simulateType). -- Если игра начнёт путать/терять символы — поставь например 8 или 15, -- вернётся старый надёжный, но медленный режим "по одному символу". type_delay_ms = 0, -- Пытаться ли самому нажимать Enter после ввода текста (полностью -- без рук). Если у вас Enter в диалогах SA-MP не работает как "Далее", -- поставь false — тогда придётся нажимать "Далее" вручную, как раньше. auto_submit = true, -- Показывать ли всплывающий список заготовок под настоящим окном ввода -- сервера, когда открываешь /mypame сам (без номера). Работает только -- если у тебя есть библиотека mimgui. Переключается ещё и /mypame picker. picker_enabled = true, -- Когда включено: клик по пункту "Пресеты" в НАСТОЯЩЕМ меню сервера не -- уходит на сервер, а вместо этого открывает список наших локальных -- заготовок — выбор сразу заполняет карточку выбранной заготовкой. -- Переключается ещё и /mypame presets. presets_hijack_enabled = true, -- Следить за сменой скина и предлагать применить заготовку, привязанную -- к новому скину (привязка: пункт "Привязать к скину" в редакторе или -- команда /mypame bind <номер> [id скина]). Переключается /mypame skin. skin_prompt_enabled = true, -- Если у нового скина НЕТ привязанных заготовок — всё равно предложить -- открыть общий список заготовок. Переключается /mypame skinall. skin_offer_when_unbound = false, -- Сколько мс скин должен продержаться неизменным, прежде чем предложить -- заменить описание (защита от мерцания скина при спавне/в магазине одежды). skin_prompt_delay_ms = 1500, } for k, v in pairs(CONFIG) do if k:find('^marker_') then CONFIG[k] = T(v) end end ---------------------------------------------------------------- -- Хранение заготовок ---------------------------------------------------------------- local presetsDir = getWorkingDirectory() .. '\\config\\' local presetsFile = presetsDir .. 'mypame_presets.txt' local presets = {} local function ensureDir() if not doesDirectoryExist(presetsDir) then createDirectory(presetsDir) end end local function escapeField(s) s = s or '' return s:gsub('\\', '\\\\'):gsub('\n', '\\n'):gsub('|', '\\p') end local function unescapeField(s) return (s:gsub('\\p', '|'):gsub('\\n', '\n'):gsub('\\\\', '\\')) end -- Значения в памяти (p.desc/p.line1/p.line2) хранятся в кодировке игры (CP1251) — -- их можно напрямую слать в диалоги сервера. А вот НА ДИСКЕ файл заготовок хранится -- в UTF-8: так его можно спокойно открыть и поправить вручную в блокноте/VS Code -- без риска поломать кириллицу. Поэтому при сохранении конвертируем CP1251 -> UTF-8, -- а при загрузке — обратно UTF-8 -> CP1251. Раньше конвертации не было вообще, из-за -- чего при ручном редактировании файла (сохранённого редактором в UTF-8) значения -- на самом деле были UTF-8, но скрипт считал их CP1251 и кодировал в UTF-8 ещё раз -- перед копированием в буфer обмена — отсюда кракозябры при вставке в игру. local function toDisk(s) local ok, converted = pcall(function() return u8:encode(s or '') end) return ok and converted or (s or '') end local function fromDisk(s) local ok, converted = pcall(function() return u8:decode(s or '') end) return ok and converted or (s or '') end local function savePresets() ensureDir() local f = io.open(presetsFile, 'wb') if not f then sampAddChatMessage(T('{FF6347}[MyPame] Не удалось сохранить файл заготовок.'), -1) return end for _, p in ipairs(presets) do f:write(('%s|||%s|||%s|||%s|||%s|||%s|||%s\n'):format( escapeField(toDisk(p.desc)), escapeField(toDisk(p.line1)), escapeField(toDisk(p.line2)), escapeField(toDisk(p.line3)), escapeField(toDisk(p.descType or T('описание'))), p.favorite and '1' or '0', escapeField(p.skins or '') )) end f:close() end local function loadPresets() presets = {} ensureDir() local f = io.open(presetsFile, 'rb') if not f then return end for line in f:lines() do -- новейший формат: ... |||избранное|||скины local desc, line1, line2, line3, descType, favorite, skins = line:match('^(.-)%|%|%|(.-)%|%|%|(.-)%|%|%|(.-)%|%|%|(.-)%|%|%|(.-)%|%|%|(.*)$') if not desc then -- формат без привязки к скину desc, line1, line2, line3, descType, favorite = line:match('^(.-)%|%|%|(.-)%|%|%|(.-)%|%|%|(.-)%|%|%|(.-)%|%|%|(.*)$') end if not desc then -- формат без 3-й строки (line3 появилась позже, вместе с 3-й строкой на сервере) desc, line1, line2, descType, favorite = line:match('^(.-)%|%|%|(.-)%|%|%|(.-)%|%|%|(.-)%|%|%|(.*)$') end if not desc then -- формат без столбца избранного desc, line1, line2, descType = line:match('^(.-)%|%|%|(.-)%|%|%|(.-)%|%|%|(.*)$') end if not desc then -- самый старый формат файла (до появления типа поля) desc, line1, line2 = line:match('^(.-)%|%|%|(.-)%|%|%|(.*)$') end if desc then table.insert(presets, { desc = fromDisk(unescapeField(desc)), line1 = fromDisk(unescapeField(line1)), line2 = fromDisk(unescapeField(line2)), line3 = (line3 and line3 ~= '') and fromDisk(unescapeField(line3)) or '', descType = (descType and descType ~= '') and fromDisk(unescapeField(descType)) or T('описание'), favorite = favorite == '1', skins = (skins and skins ~= '') and unescapeField(skins) or '', }) end end f:close() end local function stripColors(s) return (s:gsub('{%x%x%x%x%x%x}', '')) end -- Привязка заготовки к скинам: p.skins — строка вида "12, 45" (один или -- несколько ID скинов). Пустая строка — заготовка ни к какому скину не привязана. local function normalizeSkins(str) local out, seen = {}, {} for n in (str or ''):gmatch('%d+') do local v = tonumber(n) if v and not seen[v] then seen[v] = true table.insert(out, tostring(v)) end end return table.concat(out, ', ') end local function skinListHas(str, skinId) if not str or str == '' then return false end for n in str:gmatch('%d+') do if tonumber(n) == skinId then return true end end return false end local function skinTag(p) if p.skins and p.skins ~= '' then return T(' [скин: ') .. p.skins .. ']' end return '' end local function getCurrentSkin() if not doesCharExist(PLAYER_PED) then return nil end return getCharModel(PLAYER_PED) end ---------------------------------------------------------------- -- Локальный редактор — синхронный, через sampIsDialogActive/sampHasDialogRespond ---------------------------------------------------------------- local DLG_LIST = 20001 local DLG_ITEM = 20002 local DLG_INPUT_DESC = 20003 local DLG_INPUT_LINE1 = 20004 local DLG_INPUT_LINE2 = 20005 local DLG_INPUT_LINE3 = 20006 local DLG_DESC_TYPE = 20007 local DLG_CONFIRM_DELETE = 20008 local DLG_PRESET_QUICK = 20009 -- список локальных заготовок, открывается вместо клика по "Пресеты" local DLG_PRESET_INFO = 20010 -- предпросмотр выбранной заготовки перед применением -- Верхняя граница диапазона "это наш собственный локальный диалог, а не диалог -- сервера" — используется в глобальных обработчиках onShowDialog/onSendDialogResponse. local DLG_SKIN_OFFER = 20011 -- "скин сменился — применить заготовку?" (одна подходящая) local DLG_SKIN_LIST = 20012 -- то же, но подходящих заготовок несколько local DLG_INPUT_SKIN = 20013 -- ввод ID скина для привязки local DLG_SKIN_UNBOUND = 20014 -- у нового скина нет привязок — открыть общий список? local DLG_MAX_LOCAL = DLG_SKIN_UNBOUND local editorRunning = false -- показывает LIST-диалог и блокирует текущий поток, пока не придёт ответ local function showListBlocking(id, title, text, btn1, btn2) sampShowDialog(id, title, text, btn1, btn2, 2) while sampIsDialogActive(id) do wait(0) end local _, button, listitem = sampHasDialogRespond(id) return button, listitem end -- показывает INPUT-диалог, возвращает введённый текст или nil, если отменили local function askInput(id, title, text) sampShowDialog(id, title, text, T('Сохранить'), T('Отмена'), 1) while sampIsDialogActive(id) do wait(0) end local _, button, _, input = sampHasDialogRespond(id) if button ~= 1 then return nil end return input end -- показывает LIST-диалог для выбора типа поля (описание/слухи) local function askDescType() local text = T('Описание\nСлухи') local button, listitem = showListBlocking(DLG_DESC_TYPE, T('Тип поля'), text, T('Выбрать'), T('Отмена')) if button ~= 1 then return nil end if listitem == 0 then return T('описание') end return T('слухи') end -- показывает MSGBOX-диалог "Да/Нет", возвращает true, если нажали "Да" local function askConfirm(id, title, text) sampShowDialog(id, title, text, T('Да'), T('Нет'), 0) while sampIsDialogActive(id) do wait(0) end local _, button = sampHasDialogRespond(id) return button == 1 end -- Порядок показа: сначала избранные (в своём исходном порядке), потом -- остальные — используется и в редакторе, и во всплывающем списке заготовок, -- и при перестановке местами (Поднять/Опустить), чтобы у всех троих было -- одинаковое понятие "кто за кем идёт". local function computeDisplayOrder() local order = {} for i in ipairs(presets) do if presets[i].favorite then table.insert(order, i) end end for i in ipairs(presets) do if not presets[i].favorite then table.insert(order, i) end end return order end -- Меняет местами заготовку с номером i (в массиве presets) и её соседа по -- ОТОБРАЖАЕМОМУ списку в направлении direction (-1 — вверх, +1 — вниз). -- Не даёт перепрыгнуть через границу "избранное / не избранное" — сосед -- должен быть из той же группы, иначе перестановка не имеет смысла (список -- всё равно всегда показывает сначала все избранные). -- Возвращает (новый индекс этой же заготовки, удалось ли переставить). local function movePresetInDisplay(i, direction) local order = computeDisplayOrder() local pos for k, idx in ipairs(order) do if idx == i then pos = k; break end end if not pos then return i, false end local neighborIdx = order[pos + direction] if not neighborIdx then return i, false end if presets[neighborIdx].favorite ~= presets[i].favorite then return i, false end presets[i], presets[neighborIdx] = presets[neighborIdx], presets[i] savePresets() return neighborIdx, true end local function runItemMenu(i) while true do local p = presets[i] if not p then return end -- Инфу о текущих значениях показываем в чат отдельно, а не как строки -- LIST-диалога — иначе они сами станут пунктами списка и собьют индексы -- (в SAMP style=2/LIST каждая строка text — отдельный selectable-пункт). sampAddChatMessage(T('{808080}Название: ') .. ((p.desc and p.desc ~= '') and p.desc or T('(без названия)')), -1) sampAddChatMessage(T('{808080}Первая строка: ') .. (p.line1 or ''), -1) sampAddChatMessage(T('{808080}Вторая строка: ') .. (p.line2 or ''), -1) sampAddChatMessage(T('{808080}Третья строка: ') .. (p.line3 or ''), -1) sampAddChatMessage(T('{808080}Тип поля: ') .. ((p.descType and p.descType ~= '') and p.descType or T('описание')), -1) sampAddChatMessage(T('{808080}Избранное: ') .. (p.favorite and T('да *') or T('нет')), -1) sampAddChatMessage(T('{808080}Привязка к скину: ') .. ((p.skins and p.skins ~= '') and p.skins or T('нет')), -1) local favLabel = p.favorite and T('Убрать из избранного') or T('Добавить в избранное') local text = T('Изменить название\nИзменить первую строку\nИзменить вторую строку\nИзменить третью строку\nИзменить тип (описание/слухи)\n') .. favLabel .. T('\nПоднять вверх\nОпустить вниз\nПривязать к скину\nУдалить заготовку') local header = T('Заготовка #') .. tostring(i) if p.favorite then header = header .. T(' *') end if p.desc and p.desc ~= '' then header = header .. T(': ') .. p.desc end local button, listitem = showListBlocking(DLG_ITEM, header, text, T('Выбрать'), T('Назад')) if button ~= 1 then return end if listitem == 0 then local v = askInput(DLG_INPUT_DESC, T('Новое название'), T('Введите название заготовки:')) if v then p.desc = v; savePresets() end elseif listitem == 1 then local v = askInput(DLG_INPUT_LINE1, T('Новая первая строка'), T('Введите текст:')) if v then p.line1 = v; savePresets() end elseif listitem == 2 then local v = askInput(DLG_INPUT_LINE2, T('Новая вторая строка'), T('Введите текст:')) if v then p.line2 = v; savePresets() end elseif listitem == 3 then local v = askInput(DLG_INPUT_LINE3, T('Новая третья строка'), T('Введите текст:')) if v then p.line3 = v; savePresets() end elseif listitem == 4 then local v = askDescType() if v then p.descType = v; savePresets() end elseif listitem == 5 then p.favorite = not p.favorite savePresets() sampAddChatMessage(p.favorite and T('{7FFF00}[MyPame] Добавлено в избранное.') or T('{7FFF00}[MyPame] Убрано из избранного.'), -1) elseif listitem == 6 then local newI, moved = movePresetInDisplay(i, -1) if moved then i = newI sampAddChatMessage(T('{7FFF00}[MyPame] Заготовка поднята вверх.'), -1) else sampAddChatMessage(T('{FF6347}[MyPame] Уже в самом верху своей группы (избранное/обычные отдельно).'), -1) end elseif listitem == 7 then local newI, moved = movePresetInDisplay(i, 1) if moved then i = newI sampAddChatMessage(T('{7FFF00}[MyPame] Заготовка опущена вниз.'), -1) else sampAddChatMessage(T('{FF6347}[MyPame] Уже в самом низу своей группы (избранное/обычные отдельно).'), -1) end elseif listitem == 8 then local cur = getCurrentSkin() local v = askInput(DLG_INPUT_SKIN, T('Привязка к скину'), T('Введите ID скина (несколько — через запятую).\nТекущий скин: ') .. tostring(cur or '?') .. T('\nОставьте пустым, чтобы убрать привязку.')) if v then p.skins = normalizeSkins(v) savePresets() sampAddChatMessage(p.skins ~= '' and (T('{7FFF00}[MyPame] Заготовка привязана к скину: ') .. p.skins) or T('{7FFF00}[MyPame] Привязка к скину убрана.'), -1) end elseif listitem == 9 then local sure = askConfirm(DLG_CONFIRM_DELETE, T('Удалить заготовку?'), T('Вы точно хотите удалить эту заготовку? Это действие нельзя отменить.')) if sure then table.remove(presets, i) savePresets() sampAddChatMessage(T('{7FFF00}[MyPame] Заготовка удалена.'), -1) return end end end end local function runEditor() while true do -- Сначала избранные (в своём исходном порядке), потом остальные — -- порядковые номера заготовок (#i) при этом не меняются, меняется -- только то, в каком порядке они показаны в этом списке. local order = computeDisplayOrder() local text = '' for _, i in ipairs(order) do local p = presets[i] local preview = (p.desc and p.desc ~= '') and p.desc or (p.line1 ~= '' and p.line1 or '') local star = p.favorite and T('* ') or '' text = text .. star .. tostring(i) .. '. ' .. preview .. skinTag(p) .. '\n' end text = text .. T('+ Добавить новую заготовку') local button, listitem = showListBlocking(DLG_LIST, T('Мои заготовки /mypame'), text, T('Выбрать'), T('Закрыть')) if button ~= 1 then return end if listitem + 1 > #order then local desc = askInput(DLG_INPUT_DESC, T('Новая заготовка: название'), T('Введите название (для себя, необязательно):')) if desc ~= nil then local line1 = askInput(DLG_INPUT_LINE1, T('Новая заготовка: первая строка'), T('Введите первую строку:')) if line1 then local line2 = askInput(DLG_INPUT_LINE2, T('Новая заготовка: вторая строка'), T('Введите вторую строку:')) if line2 then -- Третья строка необязательна: "Отмена" здесь просто оставляет её пустой, -- а не отменяет создание всей заготовки. local line3 = askInput(DLG_INPUT_LINE3, T('Новая заготовка: третья строка'), T('Введите третью строку (необязательно — можно нажать "Отмена"):')) or '' table.insert(presets, { desc = desc, line1 = line1, line2 = line2, line3 = line3, descType = T('описание'), favorite = false, skins = '' }) savePresets() sampAddChatMessage(T('{7FFF00}[MyPame] Заготовка #') .. tostring(#presets) .. T(' сохранена.'), -1) end end end else runItemMenu(order[listitem + 1]) end end end local automation = { active = false } local pendingRealDialog = nil local lastRawDialog = nil ---------------------------------------------------------------- -- Всплывающий список заготовок (ручной режим: сам открыл /mypame, -- сам выбрал "первую"/"вторую строку" в меню сервера — а под окошком -- ввода появляется список твоих заготовок, кликаешь — текст вставляется) ---------------------------------------------------------------- local lastMainMenuText = nil -- текст последнего показанного главного меню сервера local lastMenuDescType = nil -- какой тип поля сейчас у сервера ("описание" / "слухи"), как есть, из текста меню local pendingFieldGuess = nil -- 'line1' / 'line2' / 'line3' / nil — какое поле выбрали в меню local pickerSelection = nil -- значение заготовки, которое надо вставить (ставится из imgui-клика) local presetPicker = { visible = false, field = nil, dialogId = nil, descType = nil } -- Ставится в true, когда в НАСТОЯЩЕМ меню сервера кликнули "Пресеты" — фоновый -- поток в main() подхватывает это и открывает список локальных заготовок. local quickPresetRequested = false local function presetOrderForPicker() return computeDisplayOrder() end -- Сравнивает тип поля заготовки с тем, что сейчас показывает сервер. -- Если тип сервера не удалось определить (nil) — фильтр не применяется, -- показываем все заготовки, чтобы список не оказался пустым просто так. local function descTypeMatches(presetType, serverType) if not serverType or serverType == '' then return true end presetType = presetType or '' if presetType == '' then return false end return presetType:find(serverType, 1, true) ~= nil or serverType:find(presetType, 1, true) ~= nil end if imgui_ok then local pickerOpenRef = imgui.new.bool(true) local FIELD_LABELS = { line1 = 'первая строка', line2 = 'вторая строка', line3 = 'третья строка' } local FIELD_ORDER = { 'line1', 'line2', 'line3' } local FIELD_NUM = { line1 = '1', line2 = '2', line3 = '3' } imgui.OnFrame(function() return presetPicker.visible end, function() imgui.SetNextWindowSize(imgui.ImVec2(460, 360), imgui.Cond.FirstUseEver) pickerOpenRef[0] = true local fieldLabel = FIELD_LABELS[presetPicker.field] or 'строка' local typeLabel = presetPicker.descType and (' \226\128\148 ' .. toDisk(presetPicker.descType)) or '' imgui.Begin('Заготовки MyPame: ' .. fieldLabel .. typeLabel, pickerOpenRef, imgui.WindowFlags.NoCollapse) if #presets == 0 then imgui.TextDisabled('Нет заготовок. Набери /mypame edit, чтобы добавить.') else local shown = 0 for _, i in ipairs(presetOrderForPicker()) do local p = presets[i] if descTypeMatches(p.descType, presetPicker.descType) then local val = presetPicker.field and p[presetPicker.field] if val and val ~= '' then shown = shown + 1 if shown > 1 then imgui.Separator() end -- Заголовок блока-заготовки: номер, звёздочка избранного, -- название (если есть) и её тип поля (описание/слухи). local nameBadge = (p.desc and p.desc ~= '') and (' \226\128\148 ' .. toDisk(p.desc)) or '' local typeBadge = ' [' .. toDisk((p.descType and p.descType ~= '') and p.descType or T('описание')) .. ']' imgui.TextDisabled((p.favorite and '* ' or '') .. '#' .. tostring(i) .. nameBadge .. typeBadge) -- Предпросмотр всех трёх строк заготовки: активная (та, что -- сейчас редактируется) — кликабельна, остальные — просто для контекста. for _, key in ipairs(FIELD_ORDER) do local preview = (p[key] and p[key] ~= '') and toDisk(p[key]) or nil if preview then if presetPicker.field == key then if imgui.Selectable(FIELD_NUM[key] .. ': ' .. preview, false) then pickerSelection = p[key] presetPicker.visible = false end else imgui.TextDisabled(FIELD_NUM[key] .. ': ' .. preview) end end end end end end if shown == 0 then imgui.TextDisabled('Нет заготовок с таким типом поля (описание/слухи) и непустой нужной строкой.') end end imgui.End() if not pickerOpenRef[0] then presetPicker.visible = false end end) end local function matchesServerMenu(text) local t = stripColors(text) return t:find(CONFIG.marker_view, 1, true) and t:find(CONFIG.marker_line1, 1, true) and t:find(CONFIG.marker_line2, 1, true) and t:find(CONFIG.marker_line3, 1, true) end local function findItemIndex(text, marker, skipDescLine) local t = stripColors(text) local i = 0 for line in (t .. '\n'):gmatch('(.-)\n') do local isDescLine = line:find(CONFIG.marker_desc, 1, true) ~= nil if not (skipDescLine and isDescLine) and marker and line:find(marker, 1, true) then return i, line end i = i + 1 end return nil end -- Ищет пункт "Редактировать: <тип>" и возвращает (индекс, что написано после -- двоеточия — т.е. текущий тип поля: "описание" или "слухи"). local function findDescTypeItem(text) local t = stripColors(text) local i = 0 for line in (t .. '\n'):gmatch('(.-)\n') do local s, e = line:find(CONFIG.marker_desc, 1, true) if s then local after = line:sub(e + 1):gsub('^%s+', ''):gsub('%s+$', '') return i, after end i = i + 1 end return nil, nil end local function waitDialogActive(id, timeoutMs) local waited = 0 while not sampIsDialogActive(id) and waited < timeoutMs do wait(0) waited = waited + 1 end return sampIsDialogActive(id) end local function respondToServerDialog(id, response, listitem, inputText) local becameActive = waitDialogActive(id, 1000) if automation.debug then local curId = sampGetCurrentDialogId and sampGetCurrentDialogId() or -1 sampAddChatMessage(T('{808080}[DBG] отправляю ответ: target_id=') .. tostring(id) .. T(' active=') .. tostring(becameActive) .. T(' current_dialog_id=') .. tostring(curId) .. T(' response=') .. tostring(response) .. T(' listitem=') .. tostring(listitem) .. T(' text="') .. tostring(inputText) .. T('"'), -1) end local ok, err = pcall(sampSendDialogResponse, id, response, listitem, inputText or '') if automation.debug and not ok then sampAddChatMessage(T('{FF6347}[DBG] sampSendDialogResponse упал с ошибкой: ') .. tostring(err), -1) end end sampev.onShowDialog = function(dialogId, style, title, button1, button2, text) -- игнорируем свои же локальные диалоги редактора if dialogId >= DLG_LIST and dialogId <= DLG_MAX_LOCAL then return end lastRawDialog = { id = dialogId, style = style, title = title, text = text } if not automation.active then -- Ручной режим (без /mypame <номер>): сами следим за диалогами, -- чтобы понять, когда открылось окошко ввода первой/второй строки, -- и показать под ним список заготовок. if matchesServerMenu(text) then lastMainMenuText = text local _, curType = findDescTypeItem(text) lastMenuDescType = curType presetPicker.visible = false elseif style == 1 or style == 3 then if imgui_ok and CONFIG.picker_enabled and pendingFieldGuess then presetPicker.field = pendingFieldGuess presetPicker.dialogId = dialogId presetPicker.descType = lastMenuDescType presetPicker.visible = true end else presetPicker.visible = false end return end if matchesServerMenu(text) then pendingRealDialog = { id = dialogId, style = style, text = text, isMenu = true } else pendingRealDialog = { id = dialogId, style = style, text = text, isMenu = false } end if automation.debug then sampAddChatMessage(T('{808080}[DBG] id=') .. tostring(dialogId) .. T(' style=') .. tostring(style), -1) for line in (text .. '\n'):gmatch('(.-)\n') do sampAddChatMessage(T('{808080}[DBG] > ') .. line, -1) end end end -- Ловим клик игрока по настоящему меню сервера (ручной режим), чтобы понять, -- какое поле он выбрал ("строку 1" / "2" / "3") — это нужно, чтобы под -- следующим окном ввода показать список заготовок нужного поля. А клик по -- "Пресеты" — перехватываем полностью: отменяем отправку серверу (return -- false ниже) и вместо этого сами показываем список локальных заготовок. sampev.onSendDialogResponse = function(dialogId, response, listitem, inputtext) if dialogId >= DLG_LIST and dialogId <= DLG_MAX_LOCAL then return end if automation.active then return end if response == 1 and lastMainMenuText and matchesServerMenu(lastMainMenuText) then if CONFIG.presets_hijack_enabled then local idxPresets = findItemIndex(lastMainMenuText, CONFIG.marker_presets, true) if idxPresets and listitem == idxPresets then pendingFieldGuess = nil quickPresetRequested = true return false -- не даём клику уйти на сервер end end local idx1 = findItemIndex(lastMainMenuText, CONFIG.marker_line1, true) local idx2 = findItemIndex(lastMainMenuText, CONFIG.marker_line2, true) local idx3 = findItemIndex(lastMainMenuText, CONFIG.marker_line3, true) if idx1 and listitem == idx1 then pendingFieldGuess = 'line1' elseif idx2 and listitem == idx2 then pendingFieldGuess = 'line2' elseif idx3 and listitem == idx3 then pendingFieldGuess = 'line3' else pendingFieldGuess = nil end else pendingFieldGuess = nil end end local function runAutomation(preset) automation.active = true automation.debug = false pendingRealDialog = nil sampSendChat(CONFIG.real_command) local function waitDialog(timeoutMs) local waited = 0 while not pendingRealDialog and waited < timeoutMs do wait(10) waited = waited + 10 end local d = pendingRealDialog pendingRealDialog = nil return d end local function resolveToInput(preferredMarker) for _ = 1, 5 do -- защита от зацикливания local d = waitDialog(4000) if not d then return nil end if d.style == 1 or d.style == 3 then return d -- это и есть поле ввода end if d.isMenu then return nil -- сервер почему-то вернул в главное меню — что-то пошло не так end local idx, matchedLine = nil, nil if preferredMarker then idx, matchedLine = findItemIndex(d.text, preferredMarker, true) end if not idx then idx = 0 end -- fallback: берём первый пункт, если маркер не задан/не найден if automation.debug then sampAddChatMessage(T('{808080}[DBG] выбираю пункт #') .. tostring(idx) .. T(' в подменю | ') .. tostring(matchedLine), -1) end respondToServerDialog(d.id, 1, idx, '') end return nil end -- Сервер всегда открывает /mypame с полем "описание" по умолчанию. -- Если в заготовке выбран тип "слухи" (или наоборот отличается от того, -- что сейчас показывает сервер) — сначала переключаем это поле, кликнув -- по пункту "Редактировать: ...", и только потом переходим к строкам. local wantedDescType = (preset.descType and preset.descType ~= '') and preset.descType or T('описание') do local menu = waitDialog(4000) if not menu or not menu.isMenu then sampAddChatMessage(T('{FF6347}[MyPame] Не дождался меню сервера. Набери /mypame debug и пришли, что выведет.'), -1) automation.active = false return end local idx, currentType = findDescTypeItem(menu.text) if not idx then sampAddChatMessage(T('{FF6347}[MyPame] Не нашёл пункт "Редактировать: ..." в меню сервера.'), -1) automation.active = false return end if not (currentType and currentType:find(wantedDescType, 1, true)) then if automation.debug then sampAddChatMessage(T('{808080}[DBG] переключаю тип поля: "') .. tostring(currentType) .. T('" -> "') .. wantedDescType .. T('"'), -1) end respondToServerDialog(menu.id, 1, idx, '') local after = waitDialog(4000) if not after then sampAddChatMessage(T('{FF6347}[MyPame] Не дождался ответа сервера после переключения описание/слухи.'), -1) automation.active = false return end -- Если после клика сервер попросил что-то ввести (а не сам -- переключил тип и вернул то же меню) — вводим желаемый тип. if after.style == 1 or after.style == 3 then simulateBackspaceAll(140, 0) local typed = simulateType(wantedDescType, CONFIG.type_delay_ms) if typed and CONFIG.auto_submit then wait(80) pendingRealDialog = nil simulateSubmit() after = waitDialog(4000) if not after then sampAddChatMessage(T('{FF6347}[MyPame] Не дождался перехода после переключения описание/слухи.'), -1) automation.active = false return end end end pendingRealDialog = after else pendingRealDialog = menu end end local fields = { { key = 'line1', marker = CONFIG.marker_line1 }, { key = 'line2', marker = CONFIG.marker_line2 }, { key = 'line3', marker = CONFIG.marker_line3 }, } for _, field in ipairs(fields) do local value = preset[field.key] if value and value ~= '' then local menu = waitDialog(4000) if not menu or not menu.isMenu then sampAddChatMessage(T('{FF6347}[MyPame] Не дождался меню сервера. Набери /mypame debug и пришли, что выведет.'), -1) automation.active = false return end local idx, matchedLine = findItemIndex(menu.text, field.marker, true) if not idx then sampAddChatMessage(T('{FF6347}[MyPame] Не нашёл нужный пункт в меню сервера.'), -1) automation.active = false return end if automation.debug then sampAddChatMessage(T('{808080}[DBG] поле "') .. field.key .. T('" -> пункт #') .. tostring(idx) .. T(' | ') .. tostring(matchedLine), -1) end respondToServerDialog(menu.id, 1, idx, '') local input = resolveToInput(nil) if not input then sampAddChatMessage(T('{FF6347}[MyPame] Не дождался поля ввода (возможно, появилось незнакомое подменю). Набери /mypame debug сразу после зависания.'), -1) automation.active = false return end simulateBackspaceAll(140, 0) local typed = simulateType(value, CONFIG.type_delay_ms) if not typed then sampAddChatMessage(T('{FF6347}[MyPame] Не удалось получить окно игры для эмуляции ввода. Впиши вручную и нажми "Далее": ') .. value, -1) pendingRealDialog = nil local afterManual = waitDialog(120000) if not afterManual then sampAddChatMessage(T('{FF6347}[MyPame] Не дождался, что поле "') .. field.key .. T('" будет пройдено вручную. Останавливаюсь.'), -1) automation.active = false return end pendingRealDialog = afterManual elseif CONFIG.auto_submit then wait(80) pendingRealDialog = nil simulateSubmit() local afterSubmit = waitDialog(4000) if not afterSubmit then sampAddChatMessage(T('{FF6347}[MyPame] Поле "') .. field.key .. T('" введено, но авто-Enter не сработал. Нажми "Далее" вручную.'), -1) local afterManual = waitDialog(120000) if not afterManual then sampAddChatMessage(T('{FF6347}[MyPame] Не дождался перехода. Останавливаюсь.'), -1) automation.active = false return end pendingRealDialog = afterManual else pendingRealDialog = afterSubmit end else sampAddChatMessage(T('{7FFF00}[MyPame] Текст введён. Проверь поле и нажми "Далее".'), -1) pendingRealDialog = nil local afterManual = waitDialog(120000) if not afterManual then sampAddChatMessage(T('{FF6347}[MyPame] Не дождался, что поле "') .. field.key .. T('" будет пройдено вручную. Останавливаюсь.'), -1) automation.active = false return end pendingRealDialog = afterManual end end end local final = waitDialog(1500) if final then sampSendChat(CONFIG.real_command) end automation.active = false sampAddChatMessage(T('{7FFF00}[MyPame] Готово.'), -1) end ---------------------------------------------------------------- -- Локальный список заготовок вместо клика по "Пресеты" в настоящем меню ---------------------------------------------------------------- local function runQuickPresetPicker() if #presets == 0 then sampAddChatMessage(T('{FF6347}[MyPame] Нет сохранённых заготовок. /mypame edit — чтобы добавить.'), -1) return end if automation.active then return end while true do local order = computeDisplayOrder() local text = '' for _, i in ipairs(order) do local p = presets[i] local preview = (p.desc and p.desc ~= '') and p.desc or (p.line1 ~= '' and p.line1 or T('(пусто)')) local star = p.favorite and T('* ') or '' local typeBadge = ' [' .. ((p.descType and p.descType ~= '') and p.descType or T('описание')) .. ']' text = text .. star .. tostring(i) .. '. ' .. preview .. typeBadge .. skinTag(p) .. '\n' end text = (text:gsub('\n$', '')) local button, listitem = showListBlocking(DLG_PRESET_QUICK, T('Локальные заготовки'), text, T('Посмотреть'), T('Отмена')) if button ~= 1 then return end local chosen = order[listitem + 1] local p = chosen and presets[chosen] if not p then return end -- Предпросмотр перед применением: тип поля и все три строки — -- ничего не отправляется на сервер, пока не подтвердишь. local typeText = (p.descType and p.descType ~= '') and p.descType or T('описание') local infoText = T('Тип: ') .. typeText .. '\n' .. T('1: ') .. ((p.line1 and p.line1 ~= '') and p.line1 or T('(пусто)')) .. '\n' .. T('2: ') .. ((p.line2 and p.line2 ~= '') and p.line2 or T('(пусто)')) .. '\n' .. T('3: ') .. ((p.line3 and p.line3 ~= '') and p.line3 or T('(пусто)')) local header = T('Заготовка #') .. tostring(chosen) if p.favorite then header = header .. T(' *') end if p.desc and p.desc ~= '' then header = header .. T(': ') .. p.desc end if askConfirm(DLG_PRESET_INFO, header, infoText) then lua_thread.create(runAutomation, p) return end -- "Нет" — возвращаемся к списку заготовок, а не выходим совсем end end ---------------------------------------------------------------- -- Смена скина -> предложение заменить описание ---------------------------------------------------------------- local function describePreset(p) local typeText = (p.descType and p.descType ~= '') and p.descType or T('описание') return T('Тип: ') .. typeText .. '\n' .. T('1: ') .. ((p.line1 and p.line1 ~= '') and p.line1 or T('(пусто)')) .. '\n' .. T('2: ') .. ((p.line2 and p.line2 ~= '') and p.line2 or T('(пусто)')) .. '\n' .. T('3: ') .. ((p.line3 and p.line3 ~= '') and p.line3 or T('(пусто)')) end -- Ждём, пока никакой диалог/редактор/автоматизация не заняты, чтобы наше -- предложение не перебило, например, диалог магазина одежды на сервере. local function waitUntilIdle(timeoutMs) local waited = 0 while automation.active or editorRunning or sampIsDialogActive() do wait(100) waited = waited + 100 if waited >= timeoutMs then return false end end return true end local function offerForSkin(skin) local matches = {} for _, i in ipairs(computeDisplayOrder()) do if skinListHas(presets[i].skins, skin) then table.insert(matches, i) end end if #matches == 0 then if CONFIG.skin_offer_when_unbound and #presets > 0 then local open = askConfirm(DLG_SKIN_UNBOUND, T('Смена скина'), T('Скин изменён на ID ') .. tostring(skin) .. T('.\nДля него нет привязанных заготовок.\n\nОткрыть список заготовок?')) if open then runQuickPresetPicker() end else sampAddChatMessage(T('{808080}[MyPame] Скин изменён на ID ') .. tostring(skin) .. T('. Привязанных заготовок нет — /mypame bind <номер>, чтобы привязать.'), -1) end return end local chosen if #matches == 1 then local p = presets[matches[1]] local header = T('Заготовка #') .. tostring(matches[1]) if p.desc and p.desc ~= '' then header = header .. T(': ') .. p.desc end local ok = askConfirm(DLG_SKIN_OFFER, T('Смена скина'), T('Скин изменён на ID ') .. tostring(skin) .. T('.\nЗаменить описание на заготовку?\n\n') .. header .. '\n' .. describePreset(p)) if ok then chosen = p end else local text = '' for _, i in ipairs(matches) do local p = presets[i] local preview = (p.desc and p.desc ~= '') and p.desc or (p.line1 ~= '' and p.line1 or T('(пусто)')) text = text .. tostring(i) .. '. ' .. preview .. '\n' end text = (text:gsub('\n$', '')) local button, listitem = showListBlocking(DLG_SKIN_LIST, T('Скин ') .. tostring(skin) .. T(': выбери описание'), text, T('Применить'), T('Отмена')) if button == 1 then chosen = presets[matches[listitem + 1]] end end if chosen then lua_thread.create(runAutomation, chosen) end end local skinState = { last = nil, spawned = false, skin = nil } local function skinWatcher() local lastSkin = nil -- последний "принятый" скин local candidate = nil -- новый скин, который ждёт, пока "устоится" local candidateSince = 0 while true do wait(250) local okSpawn, spawned = pcall(sampIsLocalPlayerSpawned) skinState.spawned = okSpawn and spawned or false skinState.skin = getCurrentSkin() skinState.last = lastSkin if CONFIG.skin_prompt_enabled and isSampAvailable() and skinState.spawned then local skin = getCurrentSkin() if skin then if lastSkin == nil then lastSkin = skin -- первое наблюдение — запоминаем молча elseif skin ~= lastSkin then if candidate ~= skin then candidate = skin candidateSince = os.clock() elseif (os.clock() - candidateSince) * 1000 >= CONFIG.skin_prompt_delay_ms then candidate = nil lastSkin = skin if waitUntilIdle(30000) and getCurrentSkin() == skin then offerForSkin(skin) end end else candidate = nil end end else -- не заспавнен / фича выключена: забываем состояние, чтобы после -- респавна/включения не получить ложное предложение lastSkin, candidate = nil, nil end end end ---------------------------------------------------------------- -- Команда ---------------------------------------------------------------- sampRegisterChatCommand(CONFIG.command, function(param) param = param:gsub('^%s+', ''):gsub('%s+$', '') if param == '' then sampSendChat(CONFIG.real_command) return end if param == 'testclip' then local testWord = T('тест') local clipboardValue = testWord if CONFIG.clipboard_mode == 'utf8' then local ok, converted = pcall(function() return u8:encode(testWord) end) if ok and converted then clipboardValue = converted end end local ok = pcall(setClipboardText, clipboardValue) if ok then sampAddChatMessage(T('{FFFF00}[MyPame] Режим "') .. CONFIG.clipboard_mode .. T('". В буфер скопировано слово "тест". Сверни игру, открой Блокнот и вставь туда (Ctrl+V). Напиши, что получилось.'), -1) else sampAddChatMessage(T('{FF6347}[MyPame] setClipboardText упал с ошибкой.'), -1) end return end if param:find('^hex') then local n = tonumber(param:match('%d+')) if not n or not presets[n] then sampAddChatMessage(T('{FF6347}[MyPame] Укажи номер заготовки: /mypame hex <номер>'), -1) return end local function toHex(s) return (s or ''):gsub('.', function(c) return ('%02X '):format(c:byte()) end) end sampAddChatMessage(T('{808080}line1 hex: ') .. toHex(presets[n].line1), -1) sampAddChatMessage(T('{808080}line2 hex: ') .. toHex(presets[n].line2), -1) return end if param == 'edit' then if editorRunning then sampAddChatMessage(T('{FF6347}[MyPame] Редактор уже открыт.'), -1) return end lua_thread.create(function() editorRunning = true runEditor() editorRunning = false end) return end if param == 'picker' then if not imgui_ok then sampAddChatMessage(T('{FF6347}[MyPame] Библиотека mimgui не найдена, список заготовок недоступен.'), -1) return end CONFIG.picker_enabled = not CONFIG.picker_enabled if not CONFIG.picker_enabled then presetPicker.visible = false end sampAddChatMessage(CONFIG.picker_enabled and T('{7FFF00}[MyPame] Всплывающий список заготовок включён.') or T('{7FFF00}[MyPame] Всплывающий список заготовок выключен.'), -1) return end if param == 'presets' then CONFIG.presets_hijack_enabled = not CONFIG.presets_hijack_enabled sampAddChatMessage(CONFIG.presets_hijack_enabled and T('{7FFF00}[MyPame] Клик по "Пресеты" теперь открывает наши локальные заготовки.') or T('{7FFF00}[MyPame] Клик по "Пресеты" снова уходит на сервер как обычно.'), -1) return end if param == 'debug' then if not lastRawDialog then sampAddChatMessage(T('{FF6347}[MyPame] Пока не было ни одного диалога.'), -1) return end sampAddChatMessage(T('{808080}id=') .. tostring(lastRawDialog.id) .. T(' style=') .. tostring(lastRawDialog.style), -1) for line in (lastRawDialog.text .. '\n'):gmatch('(.-)\n') do sampAddChatMessage(T('{808080}> ') .. line, -1) end return end if param == 'skin' then CONFIG.skin_prompt_enabled = not CONFIG.skin_prompt_enabled sampAddChatMessage(CONFIG.skin_prompt_enabled and T('{7FFF00}[MyPame] Предложение заменить описание при смене скина включено.') or T('{7FFF00}[MyPame] Предложение заменить описание при смене скина выключено.'), -1) return end if param == 'skindebug' then sampAddChatMessage(T('{808080}[skin] включено: ') .. tostring(CONFIG.skin_prompt_enabled) .. T(' | заспавнен: ') .. tostring(skinState.spawned) .. T(' | текущий скин: ') .. tostring(skinState.skin) .. T(' | запомненный: ') .. tostring(skinState.last), -1) local any = false for i, pr in ipairs(presets) do if pr.skins and pr.skins ~= '' then any = true sampAddChatMessage(T('{808080}[skin] заготовка #') .. i .. T(' -> скины: ') .. pr.skins, -1) end end if not any then sampAddChatMessage(T('{808080}[skin] ни одна заготовка ни к какому скину не привязана'), -1) end return end if param == 'skinall' then CONFIG.skin_offer_when_unbound = not CONFIG.skin_offer_when_unbound sampAddChatMessage(CONFIG.skin_offer_when_unbound and T('{7FFF00}[MyPame] При смене скина без привязок будет предложено открыть общий список заготовок.') or T('{7FFF00}[MyPame] При смене скина без привязок скрипт молчит.'), -1) return end do local bn, rest = param:match('^bind%s+(%d+)%s*(.*)$') if bn then local bi = tonumber(bn) if not presets[bi] then sampAddChatMessage(T('{FF6347}[MyPame] Заготовки с таким номером нет. /mypame edit — список.'), -1) return end local skins = normalizeSkins(rest) if skins == '' then local cur = getCurrentSkin() skins = cur and tostring(cur) or '' end if skins == '' then sampAddChatMessage(T('{FF6347}[MyPame] Не удалось определить скин. Укажи ID: /mypame bind <номер> '), -1) return end presets[bi].skins = skins savePresets() sampAddChatMessage(T('{7FFF00}[MyPame] Заготовка #') .. bn .. T(' привязана к скину: ') .. skins, -1) return end local un = param:match('^unbind%s+(%d+)$') if un then local ui = tonumber(un) if not presets[ui] then sampAddChatMessage(T('{FF6347}[MyPame] Заготовки с таким номером нет. /mypame edit — список.'), -1) return end presets[ui].skins = '' savePresets() sampAddChatMessage(T('{7FFF00}[MyPame] Привязка к скину убрана у заготовки #') .. un .. '.', -1) return end end local n = tonumber(param) if not n or not presets[n] then sampAddChatMessage(T('{FF6347}[MyPame] Заготовки с таким номером нет. /mypame edit — список.'), -1) return end if automation.active then sampAddChatMessage(T('{FF6347}[MyPame] Уже выполняется, подожди.'), -1) return end lua_thread.create(runAutomation, presets[n]) end) function main() loadPresets() while not isSampAvailable() do wait(100) end sampAddChatMessage(T('{7FFF00}[MyPame Manager] Загружено. /mypame edit — заготовки, /mypame <номер> — применить.'), -1) sampAddChatMessage(T('{7FFF00}[MyPame Manager] Смена скина: /mypame bind <номер> [id скина] — привязать заготовку, /mypame skin — вкл/выкл.'), -1) sampAddChatMessage(T('{7FFF00}[MyPame Manager] Клик по "Пресеты" в меню сервера — тоже открывает список.'), -1) if not imgui_ok then sampAddChatMessage(T('{FFA500}[MyPame] mimgui не найден — всплывающий список заготовок при ручном /mypame будет недоступен, остальное работает как обычно.'), -1) end lua_thread.create(skinWatcher) -- Отдельный поток: следит за закрытием настоящего диалога (чтобы спрятать -- список заготовок) и подставляет текст, когда кликнули по заготовке в окне. lua_thread.create(function() while true do wait(0) if presetPicker.visible and presetPicker.dialogId and not sampIsDialogActive(presetPicker.dialogId) then presetPicker.visible = false end if pickerSelection then local val = pickerSelection pickerSelection = nil simulateBackspaceAll(140, 0) local typed = simulateType(val, CONFIG.type_delay_ms) if typed and CONFIG.auto_submit then wait(80) simulateSubmit() end end if quickPresetRequested then quickPresetRequested = false runQuickPresetPicker() end end end) wait(-1) end