C#:
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Oxide.Core;
using Oxide.Core.Plugins;
using Oxide.Game.Rust.Cui;
using Pool = Facepunch.Pool;
using Rust;
using UnityEngine;
namespace Oxide.Plugins {
[Info("RRemove", "RustInnovate", "1.6.7")]
[Description("Режимное удаление построек и предметов с возвратом ресурсов и GUI-индикатором")]
public sealed class RRemove : RustPlugin {
#region Конфигурация
private ConfigData configData = new();
private sealed class ConfigData {
// CHANGE: Единый раздел «Основные» разбит на два независимых:
// CHANGE: настройки ремува и настройки апгрейда (функции ортогональны).[JsonProperty("Настройки ремува")]
public RemoveSettings Remove = new();
[JsonProperty("Настройки апгрейда")]
public UpgradeSettings Upgrade = new();
[JsonProperty("Настройки скинов построек")]
public SkinSettings Skins = new();
[JsonProperty("GUI")]
public GuiSettings Gui = new();
}
/// <summary>
/// Настройки режима удаления: длительность режима, проценты возврата ресурсов/объектов,
/// правила доступа к чужим объектам, лимит времени на удаление, чёрный список shortname,
/// активация клавишей и встроенная система рейд/комбат-блокировки./// </summary>
internal sealed class RemoveSettings {
[JsonProperty("Время действия режима удаления")]
public float ModeDuration = 30f;
[JsonProperty("Процент возвращаемых ресурсов с построек (Максимум 1.0 - это 100%)")]
public float BuildingRefundPercent = 1.0f;
[JsonProperty("Процент возвращаемых ресурсов с Items (Максимум 1.0 - это 100%)")]
public float ItemsRefundPercent = 1.0f;
[JsonProperty("Процент выпадающих ресурсов (не вещей) с удаляемых ящиков (Максимум 1.0 - это 100%)")]
public float ContainerResourceDropPercent = 1.0f;
[JsonProperty("Включить запрет на удаление объекта если в его инвентаре есть предметы")]
public bool BlockWithItems = true;
[JsonProperty("Разрешить удаление чужих объектов при наличии авторизации в шкафу")]
public bool AllowOthersWithAuth = false;
[JsonProperty("Разрешить удаление собственных объектов без авторизации в шкафу")]
public bool AllowOwnWithoutAuth = false;
[JsonProperty("Разрешить удаление объектов соклановцев")]
public bool AllowClanmates = false;
[JsonProperty("Разрешить удаление обьектов команде игрока (Team)")]
public bool AllowTeam = true;
[JsonProperty("Включить возрат объектов (При удаление объектов(сундуки, печки и тд.) будет возращать объект а не ресурсы)")]
public bool ReturnDeployables = true;
[JsonProperty("Включить потерю прочности предмета (Item lose condition)")]
public bool ItemConditionLoss = true;
[JsonProperty("Сколько процентов прочности теряет предмет при удалении (дефолт - 10 (250 - 10% = 25))")]
public float ConditionLossPercent = 10f;
[JsonProperty("Включить запрет на удаление объекта для игрока после истечения N времени указанным в конфигурации")]
public bool EnablePlaceTimeLimit = false;
[JsonProperty("Время на запрет удаление объекта после истечения указаного времени (в секундах)")]
public float PlaceTimeLimit = 3600.0f;
[JsonProperty("Привилегия игнорирования запрета удаления объектов какие были установлены N времени назад (Если включено)")]
public string IgnorePermission = "rremove.ignore";
// CHANGE: 1.6.6 — дефолт выверен по ПРЕФАБАМ энтити (запрос владельца): нефтекубрик —
// CHANGE: refinery_small_deployed (small.oil.refinery — shortname ПРЕДМЕТА); имена
// CHANGE: предметов (chineselantern/tunalight/lantern) легитимны — IsBlockedShortname
// CHANGE: с 1.6.6 сравнивает и их.Единый источник: инициализатор + миграция.internal static readonly string[] DefaultBlockedShortnames =
{
"industrial.furnace.large", // Large Industrial Furnace
"furnace.large", // Large Furnace
"chineselantern", // Chinese Lantern (энтити: chineselantern.deployed)
"campfire", // Camp Fire
"skull_fire_pit", // Skull Fire Pit
"refinery_small_deployed", // Small Oil Refinery (предмет: small.oil.refinery)
"tunalight", // Tuna Can Lamp (энтити: tunalight.deployed)
"lantern", // Lantern (энтити: lantern.deployed)
}
;
// CHANGE: Снимок дефолта 1.6.3 — для канонической миграции списков,
// CHANGE: которые владелец не редактировал вручную.internal static readonly string[] LegacyBlockedShortnames =
{
"industrial.furnace.large", "furnace.large", "chineselantern", "chineselanternwhite", "campfire", "skull_fire_pit", "small.oil.refinery", "tunalight", "lantern",
}
;
// CHANGE: ObjectCreationHandling.Replace — иначе Newtonsoft дописывает массив из файла
// CHANGE: в список-по-умолчанию при каждой перезагрузке, и пустые строки множатся.[JsonProperty("Список запрещенных для удаления Entity shortname (Не Item)", ObjectCreationHandling = ObjectCreationHandling.Replace)]
public List<string> BlockedShortnames = new(DefaultBlockedShortnames);
[JsonProperty("Клавиша активации режима удаления")]
public string ActivationKey = "z";
// CHANGE: Открытие радиального меню без бинда: перехват клиентской консольной команды
// CHANGE: кнопки.Работает только для клавиш со своей консольной командой:
// CHANGE: F = inventory.lighttoggle (включает фонарик — конфликт), X = vehicle.swapseats.// CHANGE: По умолчанию OFF — активация строго по Z через одноразовый клиентский бинд
// CHANGE (bind z rremove.menu): клавиша Z консольной команды не имеет и сервером
// CHANGE без клиентского бинда не ловится.[JsonProperty("Кнопка открытия радиального меню без бинда (X | F | OFF)")]
public string MenuOpenButton = "OFF";
[JsonProperty("Показывать подсказку кнопки открытия рад меню при взятии киянки или плана постройки в руки")]
public bool ShowBindHint = true;
[JsonProperty("Запретить использование радиального меню и функции ремува/апгрейда в рейд-блоке (true — через внешний плагин, false — встроенная система)")]
public bool BlockInRaidZone = true;
[JsonProperty("Запретить использование радиального меню и функции ремува/апгрейда в комбат-блоке (true — через внешний плагин, false — встроенная система)")]
public bool BlockInCombatBlock = true;
// CHANGE: Встроенная система блокировки (используется при false выше): блок по взрыву
// CHANGE: по строению (рейдер + владелец) и по ПВП-урону (обе стороны).[JsonProperty("Длительность встроенной блокировки рейда (секунды)")]
public int BuiltinRaidBlockDuration = 300;
[JsonProperty("Длительность встроенной блокировки комбата (секунды)")]
public int BuiltinCombatBlockDuration = 300;
}
/// <summary>
/// Настройки режима автоапгрейда построек: длительность режима, включение функции,
/// привилегии использования и апгрейда без затрат, требование авторизации в шкафу./// </summary>
internal sealed class UpgradeSettings {
// CHANGE: Таймер режима апгрейда: каждое успешное действие сбрасывает отсчёт,
// CHANGE: бездействие приводит к выключению режима.[JsonProperty("Время действия режима апгрейда (сбрасывается при каждом апгрейде)")]
public float UpgradeModeDuration = 30f;
// CHANGE: Блок настроек автоапгрейда (строительный план + удар киянкой).[JsonProperty("Включить автоапгрейд (строительный план и удар киянкой)")]
public bool EnableAutoUpgrade = true;
[JsonProperty("Привилегия использования автоапгрейда")]
public string UpgradePermission = PermUpgrade;
[JsonProperty("Привилегия апгрейда без затрат ресурсов (для администраторов)")]
public string NoCostPermission = PermNoCost;
// CHANGE: Требование шкафа: без авторизации в шкафу зоны автоапгрейд не работает.[JsonProperty("Требовать наличие шкафа для автоапгрейда (план+киянка)")]
public bool UpgradeRequireCupboard = true;
}
// CHANGE: Скины построек (DLC): скин выбирается для КАЖДОГО типа апгрейда отдельно и
// CHANGE: распространяется и на киянку, и на строительный план (единый UpgradeState.Skin).// CHANGE: Бесплатно скины никому не выдаются: без владения DLC они доступны только
// CHANGE: по привилегии FreeSkinPermission (для администраторов)./// <summary>
/// Настройки скинов построек (DLC): списки скинов по каждому типу апгрейда
/// (выбор — пейджер Q/E у пунктов апгрейда в главном меню), привилегия бесплатного
/// доступа и автозаполнение./// Инварианты: скин применяется только если пара (тип, ID скина) есть в данных префаба
/// блока, а игрок владеет Steam-предметом скина / имеет привилегию / скин alwaysUnlock./// </summary>
internal sealed class SkinSettings {
[JsonProperty("Включить подменю скинов построек (работает вместе с автоапгрейдом)")]
public bool EnableBuildSkins = true;
// CHANGE: Ванильный пейджер: у пункта типа апгрейда в главном меню клиент рисует
// CHANGE: кнопки Q/E (nextCommand/prevCommand) — листают скины типа без подменю.[JsonProperty("Листание скинов Q/E прямо в главном меню у типов апгрейда")]
public bool EnableInlineSkinPaging = true;
// CHANGE: true — при листании Q/E колесо мгновенно переоткрывается с новым скином
// CHANGE: (клиент сам закрывает pie при исполнении команды — иначе обновление не показать);
// CHANGE: false — колесо не переоткрывается (без мерцания и сброса курсора), скин показывается тостом.[JsonProperty("Переоткрывать радиальное меню при листании скинов Q/E (false — без мерцания, скин тостом)")]
public bool ReopenMenuOnSkinPage = true;
[JsonProperty("Привилегия бесплатного использования любых скинов построек (для администраторов, мимо проверки владения DLC)")]
public string FreeSkinPermission = PermSkinsFree;
// CHANGE: 1.6.0 — выбор цвета для скинов с палитрой (контейнеры): перед запуском
// CHANGE: режима апгрейда открывается радиальное меню выбора цвета (колесо баллончика).[JsonProperty("Включить выбор цвета для скинов с палитрой (контейнеры) — перед запуском режима апгрейда")]
public bool EnableColourSelection = true;
[JsonProperty("Показывать пункт «Случайный цвет» в меню выбора цвета")]
public bool ShowRandomColour = true;
[JsonProperty("Иконка пункта «Случайный цвет»")]
public string RandomColourIcon = "assets/icons/palette.png";
// CHANGE: 1.6.6 — возвращён сплошной circle_closed.png: у контурного circle_open
// CHANGE: тонируется только кольцо, центр остаётся белым — заливки цветом нет.// CHANGE: На наведении клиент всё равно перекрашивает иконку своим hover-цветом —
// CHANGE: цвет в этот момент несут сектор и центральное превью.[JsonProperty("Иконка цветных кружков (окрашивается цветом палитры)")]
public string ColourIcon = "assets/icons/circle_closed.png";
// CHANGE: 1.6.4 — настройка «Режим отображения цвета пункта меню» (ColourOptionMode)
// CHANGE: удалена по требованию владельца: colorMode пункта всегда CustomColor (0) —
// CHANGE: иконка кружка окрашивается цветом палитры (AddPieOption).// CHANGE: Названия скинов больше не хранятся в конфиге — только ID; имена живут
// CHANGE: в файлах локализации (lang/<язык>/RRemove.json, ключи Skin{ID скина}).[JsonProperty("Автоматически добавлять найденные в игре скины в списки ниже (названия берутся из локализации lang/<язык>/RRemove.json — ключи Skin{ID скина}; выключите, чтобы полностью управлять списками вручную)")]
public bool AutoFillSkins = true;
[JsonProperty("Скины — Дерево (ID скина — Steam item definition ID скина постройки; запись с ID 0 — «Без скина»; найденные ID: консольная команда rremove.skins)", ObjectCreationHandling = ObjectCreationHandling.Replace)]
public List<SkinEntry> WoodSkins = new() {
new SkinEntry()
}
;
[JsonProperty("Скины — Камень", ObjectCreationHandling = ObjectCreationHandling.Replace)]
public List<SkinEntry> StoneSkins = new() {
new SkinEntry()
}
;
[JsonProperty("Скины — Металл", ObjectCreationHandling = ObjectCreationHandling.Replace)]
public List<SkinEntry> MetalSkins = new() {
new SkinEntry()
}
;
[JsonProperty("Скины — МВК", ObjectCreationHandling = ObjectCreationHandling.Replace)]
public List<SkinEntry> HqmSkins = new() {
new SkinEntry()
}
;
}
// CHANGE: Поля «Название» и «Иконка» удалены из конфига: имя скина — ключ локализации
// CHANGE: Skin{ID скина} (создаётся автоматически при старте, правится в lang-файле),
// CHANGE: иконки скинов не использовались (автозаполнение всегда писало пустую строку).// CHANGE: Старый конфиг читается без потерь: Newtonsoft игнорирует неизвестные поля,
// CHANGE: лишние ключи исчезнут из файла при следующем сохранении конфига./// <summary>
/// Запись скина постройки в конфиге: только Steam item definition ID скина./// Отображаемое имя — из локализации: ключ Skin{ID скина}, для ID 0 — «Без скина»
/// (ключ PieSkinNone).Инвариант: имя всегда непустое — SkinDisplayName даёт
/// fallback на имя реестра и «ID {id}»./// </summary>
internal sealed class SkinEntry {
[JsonProperty("ID скина (0 — без скина)")]
public ulong SkinId = 0UL;
}
#region Классы настроек GUI (каждая панель — отдельный класс)
internal sealed class GuiSettings {
[JsonProperty("Включить GUI-индикатор режима")]
public bool Enabled = true;
[JsonProperty("Корневая панель")]
public RootPanelConfig Root = new();
[JsonProperty("Заголовок")]
public TitleConfig Title = new();
[JsonProperty("Таймер")]
public TimerConfig Timer = new();
[JsonProperty("Подсказка")]
public HintConfig Hint = new();
[JsonProperty("Радиальное меню (нативное pie-меню клиента)")]
public PieMenuSettings Pie = new();
}
internal sealed class PieMenuSettings {
// CHANGE: Валидная иконка киянки из клиента — hammer.icon.png (hammer.png не существует).[JsonProperty("Иконка — простой ремув")]
public string DefaultIcon = "assets/prefabs/weapons/hammer/hammer.icon.png";
[JsonProperty("Иконка — админский ремув")]
public string AdminIcon = "assets/icons/explosion_sprite.png";
[JsonProperty("Иконка — удаление всех строений")]
public string AllIcon = "assets/icons/clear.png";
[JsonProperty("Иконка — закрыть меню")]
public string CloseIcon = "assets/icons/close.png";
[JsonProperty("Иконка — апгрейд: дерево")]
public string WoodIcon = "assets/icons/level_wood.png";
[JsonProperty("Иконка — апгрейд: камень")]
public string StoneIcon = "assets/icons/level_stone.png";
[JsonProperty("Иконка — апгрейд: металл")]
public string MetalIcon = "assets/icons/level_metal.png";
[JsonProperty("Иконка — апгрейд: МВК")]
public string HqmIcon = "assets/icons/level_top.png";
}
internal sealed class RootPanelConfig {
[JsonProperty("Высота")]
public float Height = 64f;
[JsonProperty("Ширина")]
public float Width = 240f;
[JsonProperty("Вверх/вниз")]
public float OffsetY = 297f;
[JsonProperty("Влево/вправо")]
public float OffsetX = 0f;
[JsonProperty("Цвет фона")]
public string BgColor = "0.16 0.16 0.17 0.85";
[JsonProperty("Цвет фона блока заголовка")]
public string HeaderBgColor = "0.20 0.62 0.90 0.95";
[JsonProperty("Высота блока заголовка")]
public float HeaderHeight = 24f;
}
// CHANGE: Тексты заголовка и подсказки перенесены из конфига в локализацию (ключи GuiTitle/GuiHint).internal sealed class TitleConfig
{
[JsonProperty("Размер шрифта")]
public int FontSize = 13;
[JsonProperty("Цвет текста")]
public string Color = "1 1 1 1";
}
internal sealed class TimerConfig {
[JsonProperty("Размер шрифта")]
public int FontSize = 18;
[JsonProperty("Цвет текста")]
public string Color = "0.25 0.69 1 1";
}
internal sealed class HintConfig {
[JsonProperty("Высота")]
public float Height = 18f;
[JsonProperty("Размер шрифта")]
public int FontSize = 11;
[JsonProperty("Цвет текста")]
public string Color = "0.75 0.75 0.75 1";
}
#endregion Классы настроек GUI
/// <inheritdoc />
protected override void LoadConfig() {
base.LoadConfig();
try {
// CHANGE: Миграция v1→v2: раздел «Основные» разбит на два.Переносим старые
// CHANGE: значения ДО основной десериализации, иначе они обнулятся на дефолты.MigrateLegacyConfig();
configData = Config.ReadObject<ConfigData>();
if (configData == null) throw new Exception();
// CHANGE: 1.6.3 — старый пустой дефолт [""] заполняется содержательным списком.MigrateBlockedShortnames();
NormalizeBlockedShortnames();
// CHANGE: Автофикс старого конфига: несуществующая иконка hammer.png давала
// CHANGE: флуд "FileSystem Not Found" на клиенте при каждом открытии меню.if (configData.Gui.Pie.DefaultIcon == "assets/icons/hammer.png")
configData.Gui.Pie.DefaultIcon = "assets/prefabs/weapons/hammer/hammer.icon.png";
if (configData.Gui.Pie.AllIcon == "assets/icons/device_add.png") configData.Gui.Pie.AllIcon = "assets/icons/clear.png";
// CHANGE: 1.6.6 — обратный автофикс 1.6.5: у контурного circle_open белая
// CHANGE: заливка (тонируется только кольцо), возвращаем сплошной кружок.if (configData.Skins.ColourIcon == "assets/icons/circle_open.png")
configData.Skins.ColourIcon = "assets/icons/circle_closed.png";
SaveConfig();
}
catch {
PrintError("Файл конфигурации поврежден или отсутствует.Создаем новый.");
LoadDefaultConfig();
SaveConfig();
}
}
/// <inheritdoc />
protected override void LoadDefaultConfig() {
PrintWarning("Создание нового файла конфигурации...");
configData = new ConfigData();
}
/// <inheritdoc />
protected override void SaveConfig() => Config.WriteObject(configData);
/// <summary>
/// Переносит конфигурацию предыдущей версии: единый раздел «Основные» разбит на
/// «Настройки ремува» и «Настройки апгрейда».Значения копируются 1:1 (лишние для
/// каждого раздела поля молча игнорируются Newtonsoft при последующей десериализации),
/// после чего устаревший раздел удаляется из файла./// Предусловие: файл конфигурации существует и читается как JSON./// Постусловие: при отсутствии раздела «Основные» файл не модифицируется./// Сложность: O(1) — размер дерева фиксирован числом настроек конфигурации./// </summary>
private void MigrateLegacyConfig() {
// CHANGE: Newtonsoft игнорирует неизвестные свойства — без миграции настройки уже
// CHANGE: работающих серверов (проценты, привилегии, чёрный список) слетели бы на дефолты.if (Config.ReadObject<JObject>() is not { } root)
return;
if (root["Основные"] is not {
Type : JTokenType.Object
}
legacy) return;
// CHANGE: DeepClone обязателен: один JToken не может принадлежать двум разделам дерева.root["Настройки ремува"] ??= legacy.DeepClone();
root["Настройки апгрейда"] ??= legacy.DeepClone();
root.Remove("Основные");
Config.WriteObject(root);
}
/// <summary>
/// Миграция чёрного списка.Конфиги до 1.6.3 содержат только пустую строку ([""]) —
/// такой список заменяется дефолтом; список, в точности равный дефолту 1.6.3
/// (LegacyBlockedShortnames), канонически обновляется до текущего дефолта
/// (small.oil.refinery → refinery_small_deployed).Пользовательские списки (не равные
/// ни одному из дефолтов) не трогаются.Внимание: полностью очищенный список вернётся
/// к дефолту при перезагрузке — «очищено» и «старый дефолт» в файле неразличимы
/// (NormalizeBlockedShortnames сохраняет пустой список как [""])./// Предусловие: configData десериализован; вызывается до NormalizeBlockedShortnames./// Постусловие: в списке есть хотя бы одна непустая запись./// Сложность: O(n), n — записей списка./// </summary>
private void MigrateBlockedShortnames() {
var entries = configData.Remove.BlockedShortnames.Where(e => !string.IsNullOrWhiteSpace(e)).Select(e => e.Trim()).ToList();
if (entries.Count == 0 || (entries.Count == RemoveSettings.LegacyBlockedShortnames.Length && !entries.Except(RemoveSettings.LegacyBlockedShortnames, StringComparer.OrdinalIgnoreCase).Any() ) ) {
configData.Remove.BlockedShortnames = new List<string>(RemoveSettings.DefaultBlockedShortnames);
}
}
/// <summary>
/// Чистит чёрный список shortname от пустых и дублирующихся записей./// Постусловие: список не пуст — при полном отсутствии записей остается одна пустая строка ""./// </summary>
private void NormalizeBlockedShortnames() {
var entries = new List<string>();
foreach (var raw in configData.Remove.BlockedShortnames) {
var entry = raw?.Trim();
if (string.IsNullOrEmpty(entry) || entries.Contains(entry)) continue;
entries.Add(entry);
}
if (entries.Count == 0) entries.Add(string.Empty);
configData.Remove.BlockedShortnames = entries;
}
#endregion Конфигурация
#region Поля и константы
private const string PermUse = "rremove.use";
private const string PermAdmin = "rremove.admin";
private const string PermUpgrade = "rremove.upgrade";
private const string PermNoCost = "rremove.nocost";
// CHANGE: Привилегия бесплатного использования скинов построек (обход проверки владения DLC).private const string PermSkinsFree = "rremove.skinsfree";
// CHANGE: Ванильный источник списка скинов построек — префаб, чьи ConstructionGrade
// CHANGE: перечисляет Admin.FindBuildingSkin (Admin.FindBuildingSkin в декомпиле,
// CHANGE: Assembly-CSharp_chunk25:17582); по нему игра сама определяет канонический
// CHANGE: набор пар (тип апгрейда, Steam ID скина).private const uint SkinListPrefabId = 2194854973u;
private const string UiRoot = "RRemove_Root";
private const string UiTimerLabel = "RRemove_Timer";
private const string UiGradeLabel = "RRemove_Grade";
// CHANGE: Единый путь data-файла (RSystem/RRemove/RRemove.json) — читается и пишется только через него.private const string DataFile = "RSystem/RRemove/RRemove";
private enum RemoveMode {
Default, Admin, All,
}
private sealed class RemoveState {
public RemoveMode Mode;
public float ExpiresAt;
}
// CHANGE: Состояние режима апгрейда: тип блока + скин постройки + момент истечения бездействия.private sealed class UpgradeState
{
public BuildingGrade.Enum Grade;
// CHANGE: Выбранный скин постройки (Steam item definition ID, 0 — стандартный):
// CHANGE: применяется и к автоапгрейду планом, и к ударам киянкой.public ulong Skin;
// CHANGE: 1.6.0 — выбранный в цветовом меню индекс цвета палитры (1-основной,
// CHANGE: 0 — случайный цвет, ванильная семантика GetStartingDetailColour(0)).public uint Colour;
public float ExpiresAt;
}
// Инвариант: ключ — userID игрока; состояние существует только пока режим активен.private readonly Dictionary<ulong, RemoveState> activeRemovers = new();
// CHANGE: Режим автоапгрейда: ключ — userID, значение — выбранный в меню тип блока.// CHANGE: Взаимоисключающ с activeRemovers: включение апгрейда гасит режим удаления и наоборот.private readonly Dictionary<ulong, UpgradeState> upgradeGrades = new();
// CHANGE: Скины построек: выбранный игроком скин на КАЖДЫЙ тип апгрейда (ключ — userID).// CHANGE: Хранится в памяти до рестарта: скин — временное предпочтение, расширять
// CHANGE: data-файл (knownUsers) ради него не нужно.private readonly Dictionary<ulong, Dictionary<BuildingGrade.Enum, ulong>> selectedSkins = new();
// CHANGE: Реестр игровых DLC-скинов построек, сгруппированный по типу апгрейда
// CHANGE: (источник — ванильный SkinListPrefabId).private readonly Dictionary<BuildingGrade.Enum, List<SkinInfo>> gameSkinRegistry = new();
// CHANGE: Плоская карта «ID скина → английское имя из игровых данных»: источник
// CHANGE: ключей локализации Skin{ID} и fallback имени без перебора реестра.O(1).private readonly Dictionary<ulong, string> skinNamesById = new();
// CHANGE: 1.6.0 — реестр палитр скинов с поддержкой цвета (колесо баллончика):
// CHANGE: ключ — Steam item definition ID скина, значение — цвета палитры
// CHANGE (ConstructionSkin_CustomDetail.ColourLookup.AllColours игровых данных).private readonly Dictionary<ulong, Color[]> colourPalettes = new();
// CHANGE: 1.6.0 — выбранный игроком цвет палитры на каждый тип апгрейда (ключ — userID,
// CHANGE: значение — 1-основной индекс цвета, 0 — случайный).Живёт в памяти до рестарта.private readonly Dictionary<ulong, Dictionary<BuildingGrade.Enum, uint>> selectedColours = new();
// CHANGE: 1.6.0 — тип апгрейда, ожидающий выбора цвета (между открытием цветового
// CHANGE: меню и выбором цвета).Инвариант: запись существует только пока открыто меню.private readonly Dictionary<ulong, BuildingGrade.Enum> pendingColourSelections = new();
// CHANGE: Игровой скин постройки: BuildingGrade.skin — это Steam item definition ID
// CHANGE: (владение проверяется по Steam-инвентарю игрока), alwaysUnlock — ванильный
// CHANGE: обход проверки владения, EnglishName — английское имя из upgradeMenu.private sealed class SkinInfo
{
public ulong SkinId;
public bool AlwaysUnlock;
public string EnglishName = string.Empty;
// CHANGE: 1.6.0 — ванильный флаг BuildingGrade.supportsColourChange: скин имеет
// CHANGE: палитру цветов (ConstructionSkin_CustomDetail + ColourLookup).public bool SupportsColourChange;
}
// CHANGE: Кэш ссылок на плагины рейд/комбат-блока; ищутся лениво (плагины могут грузиться позже нас).private Plugin? raidBlockPlugin;
private Plugin? combatBlockPlugin;
// CHANGE: Встроенная система блокировки (активна при false у флагов конфига):
// CHANGE: ключ — userID, значение — момент снятия блокировки (Time.realtimeSinceStartup).private readonly Dictionary<ulong, float> builtinRaidBlocked = new();
private readonly Dictionary<ulong, float> builtinCombatBlocked = new();
// Инвариант: ключ — NetworkableId сущности; время её установки (только что поставленные объекты).private readonly Dictionary<NetworkableId, float> placedTimes = new();
// CHANGE: Кэш «prefabID энтити → ItemDefinition» через ItemModDeployable.entityPrefab:
// CHANGE: у DLC-объектов shortname энтити не совпадает с shortname предмета,
// CHANGE: поэтому FindItemDefinition(ShortPrefabName) их не находит.private readonly Dictionary<uint, ItemDefinition> deployableItems = new();
// Инвариант: userID игроков, уже использовавших режим удаления; подсказка о бинде им не показывается.// Изменения сохраняются в data-файле — знание переживает переподключения и рестарт сервера.private HashSet<ulong> knownUsers = new();
// CHANGE: Тикер не сохраняется в поле: тип Timer из Oxide.Core недоступен при нативной
// CHANGE: компиляции Carbon, а таймеры плагина Carbon/Oxide убивают автоматически при выгрузке.// CHANGE: Нормализованная кнопка открытия меню (0 — OFF, 1 — X, 2 — F).Кэшируется при старте:
// хук OnServerCommand срабатывает на КАЖДУЮ консольную команду сервера, и аллокация строк
// (Trim/ToUpperInvariant) на каждый вызов давила на GC и роняла FPS.private int menuOpenButton = 0;
#endregion Поля и константы
#region Инициализация
/// <summary>Регистрирует права, запускает секундный тикер обновления GUI.</summary>
private void OnServerInitialized() {
// CHANGE: Одноразовая нормализация кнопки открытия меню (без аллокаций в горячих хуках)
menuOpenButton = configData.Remove.MenuOpenButton?.Trim().ToUpperInvariant() switch {
"X" => 1, "F" => 2, _ => 0,
}
;
permission.RegisterPermission(PermUse, this);
permission.RegisterPermission(PermAdmin, this);
permission.RegisterPermission(PermUpgrade, this);
permission.RegisterPermission(PermNoCost, this);
// CHANGE: Привилегия бесплатных скинов построек (обход проверки владения DLC) берётся из конфига.permission.RegisterPermission(configData.Skins.FreeSkinPermission, this);
permission.RegisterPermission(configData.Remove.IgnorePermission, this);
// CHANGE: Подсказка о бинде показывается до первого использования режима — факт
// использования сохраняется в data-файле и переживает рестарты.knownUsers =
Interface.Oxide.DataFileSystem.ReadObject<HashSet<ulong>>(DataFile) ?? new HashSet<ulong>();
BuildDeployableItemCache();
// CHANGE: Реестр скинов построек строится после загрузки конфига: автозаполнение
// CHANGE: списков скинов найденными в игре DLC-скинами + сохранение дополненного конфига.BuildSkinRegistry();
// CHANGE: 1.6.0 — реестр палитр скинов с поддержкой цвета (колесо баллончика).BuildColourPaletteRegistry();
// CHANGE: Имена скинов — ключи локализации Skin{ID}: регистрируются из игровых
// CHANGE: данных при каждом старте и редактируются сервером в lang-файлах.RegisterSkinNameMessages();
SyncSkinConfig();
timer.Every(1f, UpdateTickers);
// CHANGE: Встроенная система блокировки (OnEntityTakeDamage) нужна только когда
// CHANGE: хотя бы один из запретов работает по ней (false в конфиге).if (configData.Remove.BlockInRaidZone && configData.Remove.BlockInCombatBlock)
Unsubscribe(nameof(OnEntityTakeDamage));
}
/// <summary>Гасит GUI и состояние всех игроков (таймеры плагина снимаются автоматически).</summary>
private void Unload() {
foreach (var userId in activeRemovers.Keys.ToArray()) Deactivate(userId);
foreach (var userId in upgradeGrades.Keys.ToArray()) DeactivateUpgrade(userId);
// CHANGE: 1.6.2 — заглушка открытых pie (ReplaceOpenPiesWithStub) удалена.// CHANGE: Принудительно закрыть pie с сервера нельзя (RPC закрытия нет —
// CHANGE: CommunityEntity.Pie.cs, OpenPie только открывает), а трекинг «вероятно
// CHANGE: открытых» колёс лгал: ESC закрывает pie без всякого серверного события,
// CHANGE: поэтому после перезагрузки плагина заглушка САМА открывала белое меню
// CHANGE: с единственной «Закрыть» на пустом экране.Открытое колесо теперь
// CHANGE: переживает перезагрузку без замены: команды перерегистрируются теми же
// CHANGE: именами при загрузке плагина, а вовсе без плагина колесо закрывается
// CHANGE: первым же кликом по любому пункту (клиент закрывает pie при исполнении
// CHANGE: команды пункта — паттерн ванильного pietest).}
/// <summary>Сбрасывает режим игрока при выходе (GUI исчезает вместе с клиентом).</summary>
private void OnPlayerDisconnected(BasePlayer player, string reason) {
if (player != null) {
Deactivate(player.userID, destroyUi : false);
DeactivateUpgrade(player.userID, destroyUi : false);
// CHANGE: 1.6.0 — незавершённый выбор цвета и выбранные цвета больше не живут
// CHANGE: после выхода игрока (состояние — только пока игрок подключен).pendingColourSelections.Remove(player.userID);
selectedColours.Remove(player.userID);
}
}
/// <summary>Запоминает время установки новой постройки/предмета для лимита времени удаления.</summary>
private void OnEntityBuilt(Planner plan, GameObject go) {
var entity = go?.GetComponent<BaseEntity>();
if (entity?.net == null) return;
placedTimes[entity.net.ID] = Time.realtimeSinceStartup;
// CHANGE: Автоапгрейд: строительный план ставит твинь — сразу поднимаем блок
// CHANGE: до типа, выбранного игроком в радиальном меню.if (
configData.Upgrade.EnableAutoUpgrade && entity is BuildingBlock block && plan.GetOwnerPlayer() is {
}
builder && upgradeGrades.TryGetValue(builder.userID, out var upgradeState) ) {
// CHANGE: В рейд/комбат-блоке автоапгрейд постройки запрещён (конфиг).var buildDeny = GetBlockDenyReason(builder, out var buildLeft);
if (buildDeny != null) {
DenyToast(builder, buildDeny, buildLeft);
return;
}
UpgradeBlock(builder, block, upgradeState);
}
}
/// <summary>
/// Встроенная система блокировки (работает при false у флагов конфига):
/// взрыв по строению — рейдер (инициатор) и владелец получают рейд-блок;
/// ПВП-урон между игроками — обе стороны получают комбат-блок./// Длительности — из конфига (по умолчанию 5 минут)./// Сложность: O(1) на вызов./// </summary>
private void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) {
if (info?.InitiatorPlayer is not {
}
attacker || entity == null || attacker.IsNpc) return;
var now = Time.realtimeSinceStartup;
// Взрыв по любой постройке (включая свою) — рейд-блок инициатору и владельцу.// CHANGE: Запись и уведомление — только когда встроенная система активна (флаг false).if (
!configData.Remove.BlockInRaidZone && info.damageTypes != null && info.damageTypes.Get(Rust.DamageType.Explosion) > 0f && entity is StabilityEntity ) {
var raidDuration = Mathf.Max(1, configData.Remove.BuiltinRaidBlockDuration);
builtinRaidBlocked[attacker.userID] = now + raidDuration;
if (entity.OwnerID != 0u && entity.OwnerID != attacker.userID) builtinRaidBlocked[entity.OwnerID] = now + raidDuration;
// CHANGE: GameTip «Инициализировался рейд» — рейдеру и владельцу (если онлайн).NotifyRaidInitialized(attacker, raidDuration);
if (entity.OwnerID != 0u && BasePlayer.FindByID(entity.OwnerID) is {
}
owner && owner.userID != attacker.userID) NotifyRaidInitialized(owner, raidDuration);
}
// ПВП-урон — комбат-блок обеим сторонам (только при встроенной системе).if (
!configData.Remove.BlockInCombatBlock && entity is BasePlayer victim && !victim.IsNpc && victim.userID != attacker.userID ) {
var until = now + Mathf.Max(1, configData.Remove.BuiltinCombatBlockDuration);
builtinCombatBlocked[attacker.userID] = until;
builtinCombatBlocked[victim.userID] = until;
}
}
/// <summary>GameTip «Инициализировался рейд» с длительностью из конфига ({0} — секунды).</summary>
private void NotifyRaidInitialized(BasePlayer player, int duration) => Toast(player, Msg(player, "RaidInitialized").Replace("{0}", duration.ToString(CultureInfo.InvariantCulture)));
/// <summary>Чистит время установки уничтоженных сущностей (защита от роста словаря).</summary>
private void OnEntityDestroyed(NetworkableId id) => placedTimes.Remove(id);
/// <summary>
/// Подсказка с готовой командой бинда клавиши активации./// Показывается при каждом взятии в руки киянки (hammer/toolgun — оба вида в Rust),
/// но только пока игрок ни разу не воспользовался режимом удаления
/// (сервер не может проверить наличие клиентского бинда, поэтому сигнал — использование режима)./// </summary>
/// <summary>
/// Подсказка об открытии радиального меню при взятии в руки киянки, пистолета-строителя (toolgun)
/// или плана постройки (planner)./// X/F (открытие по кнопке, без бинда) — показывается при КАЖДОМ взятии инструмента в руки:
/// бинд не требуется, скрывать подсказку после первого использования не нужно./// OFF (активация по бинду) — только пока игрок ни разу не воспользовался режимом удаления
/// (сервер не может проверить наличие клиентского бинда, сигнал — использование режима)./// </summary>
private void OnActiveItemChanged(BasePlayer player, Item oldItem, Item newItem) {
if (player == null || !player.IsConnected || !configData.Remove.ShowBindHint) return;
if (newItem?.info == null) return;
// CHANGE: menuOpenButton кэшируется при старте — без аллокаций строк в хуке
bool openByButton = menuOpenButton != 0;
// Режим OFF: подсказка про бинд нужна только до первого использования режима.if (!openByButton && knownUsers.Contains(player.userID))
return;
var shortname = newItem.info.shortname;
if (!shortname.Equals("hammer", StringComparison.Ordinal) && !shortname.Equals("toolgun", StringComparison.Ordinal) && !shortname.Equals("planner", StringComparison.Ordinal) ) return;
player.Command("gametip.showtoast", 0, GetBindHintMessage(player));
}
/// <summary>
/// Текст подсказки об открытии радиального меню:
/// X/F — «нажмите кнопку» (открытие без бинда через перехват команды);
/// OFF — готовая команда бинда rremove.menu для консоли./// </summary>
private string GetBindHintMessage(BasePlayer player) {
if (menuOpenButton == 1) return Msg(player, "MenuOpenHint").Replace("{key}", "X");
if (menuOpenButton == 2) return Msg(player, "MenuOpenHint").Replace("{key}", "F");
return Msg(player, "BindHint").Replace("{key}", configData.Remove.ActivationKey);
}
#endregion Инициализация
#region Локализация
/// <inheritdoc />
protected override void LoadDefaultMessages() {
// CHANGE: 1.5.0 — английский словарь переведён на английский (раньше был копией русского).lang.RegisterMessages(
new Dictionary<string, string> {
["NoPermission"]
= "You don't have permission to use this command.", ["BindHint"] = "Remove mode: run this once in the console (F1): bind {key} rremove.menu", ["MenuOpenHint"] = "Remove/upgrade mode: press [{key}] to open the radial menu", ["BlockedShortname"] = "This entity is blocked from removal.", ["CannotRemovePlayers"] = "Players cannot be removed.", ["OwnNoCupboard"] = "No tool cupboard nearby: removing your own entities requires cupboard authorization.", ["OwnNotAuthed"] = "This is your entity, but you are not authorized in this building's tool cupboard.", ["OthersRemovalDisabled"] = "This entity belongs to another player.You are authorized in the cupboard, but removing others' entities is disabled in the server settings.", ["TeamRemovalDisabled"] = "This entity belongs to your teammate, but teammate removal is disabled in the server settings.", ["ClanRemovalDisabled"] = "This entity belongs to your clanmate, but clanmate removal is disabled in the server settings.", ["NotOwner"] = "This entity belongs to another player, and you are not allowed to remove it.", ["ContainerNotEmpty"] = "Cannot remove: the entity contains items.", ["QuarryRunning"] = "Cannot remove: the quarry is running.Turn it off first.", ["QuarryHasFuel"] = "Cannot remove: the quarry's fuel tank still has diesel.Take it out before removal.", ["QuarryFuelNotSpent"] = "Cannot remove: the diesel in the quarry is not fully spent yet.Let the quarry finish its work.", ["QuarryLocked"] = "Cannot remove: a lock is installed on the quarry.", ["PlaceTimeLimit"] = "This entity can no longer be removed: the removal time limit has expired.", ["AlreadyDestroyed"] = "The entity is already destroyed.", ["TimeLeft"] = "Time left: {0} s", ["GuiTitle"] = "REMOVE MODE", ["GuiAdminTitle"] = "ADMIN REMOVE", ["GuiAllTitle"] = "REMOVE BUILDING", ["GuiHint"] = "Hit an entity with the hammer to remove it", ["NoBuilding"] = "This entity is not attached to a building.", ["PieDefault"] = "Simple remove", ["PieDefaultDesc"] = "Remove your own entities with a resource refund", ["PieAdmin"] = "Admin remove", ["PieAdminDesc"] = "Remove any entities without ownership checks", ["PieAll"] = "Remove building", ["PieAllDesc"] = "Remove every entity connected to this building", ["PieClose"] = "Close", ["PieCloseDesc"] = "Exit the menu without choosing a mode", ["PieWood"] = "Wood", ["PieWoodDesc"] = "Auto-upgrade the building to wood", ["PieStone"] = "Stone", ["PieStoneDesc"] = "Auto-upgrade the building to stone", ["PieMetal"] = "Metal", ["PieMetalDesc"] = "Auto-upgrade the building to metal", ["PieHqm"] = "HQM", ["PieHqmDesc"] = "Auto-upgrade the building to high quality metal", ["GuiUpgradeTitle"] = "UPGRADE MODE", ["GuiUpgradeHint"] = "Hit with the hammer or build with the plan", ["GradeWood"] = "Wood", ["GradeStone"] = "Stone", ["GradeMetal"] = "Metal", ["GradeHqm"] = "HQM", ["UpgradeNoResources"] = "Not enough resources for the upgrade.", ["UpgradeNoBuildAuth"] = "You are not authorized to build here.", ["UpgradeNoCupboard"] = "Auto-upgrade requires authorization in this building's tool cupboard.", ["RaidBlockDenied"] = "You are raid-blocked: the radial menu, remove and upgrade are unavailable.Time left: {0} s", ["CombatBlockDenied"] = "You are combat-blocked: the radial menu, remove and upgrade are unavailable.Time left: {0} s", ["RaidInitialized"] = "Raid initialized — the radial menu, remove and upgrade are unavailable for {0} s", ["PieSkinNone"] = "No skin", ["PieSkinCurrent"] = "Skin: {0}", ["SkinNotOwned"] = "This skin is unavailable: the DLC purchase is required.", ["SkinFallback"] = "The skin is unavailable for this building — the default one is applied.", // CHANGE: 1.6.0 — цветовое колесо (палитра скина-контейнера) перед запуском режима апгрейда.// CHANGE: 1.6.1 — нумерация цветов убрана (ключ PieColourOption удалён):
// CHANGE: у колеса баллончика номеров нет, пункт называется просто «Цвет».["PieColourSwatch"] = "Colour",
["PieColourRandom"]
= "Random colour", ["PieColourRandomDesc"] = "Each upgraded block gets a random palette colour", ["PieColourCancelDesc"] = "Cancel: the upgrade mode will not start",
}
, this, "en" );
lang.RegisterMessages(new Dictionary<string, string> {
["NoPermission"]
= "У вас нет прав на использование этой команды.", ["BindHint"] = "Режим удаления: выполните один раз в консоли (F1): bind {key} rremove.menu", ["MenuOpenHint"] = "Режим удаления/апгрейда: нажмите [{key}] для открытия меню", ["BlockedShortname"] = "Этот объект запрещён к удалению.", ["CannotRemovePlayers"] = "Игроков удалять нельзя.", ["OwnNoCupboard"] = "Рядом нет шкафа: удаление своих объектов требует авторизации в шкафу.", ["OwnNotAuthed"] = "Это ваш объект, но вы не авторизованы в шкафу этого строения.", ["OthersRemovalDisabled"] = "Объект чужой, вы авторизованы в шкафу, но удаление чужих объектов отключено в настройках сервера.", ["TeamRemovalDisabled"] = "Это объект товарища по команде, но удаление объектов команды отключено в настройках сервера.", // CHANGE: Отдельная причина для клана (клановый стол) — раньше соклановцы
// CHANGE: получали сообщение про команду (Team), хотя системы разные.["ClanRemovalDisabled"] = "Это объект соклановца, но удаление объектов клана отключено в настройках сервера.",
["NotOwner"]
= "Это чужой объект, и у вас нет доступа к его удалению.", ["ContainerNotEmpty"] = "Нельзя удалить: в объекте есть предметы.", ["QuarryRunning"] = "Нельзя удалить: карьер работает.Сначала выключите его.", ["QuarryHasFuel"] = "Нельзя удалить: в топливном баке карьера есть дизель.Заберите его перед удалением.", ["QuarryFuelNotSpent"] = "Нельзя удалить: дизель в карьере не израсходован полностью.Дайте карьеру доработать.", ["QuarryLocked"] = "Нельзя удалить: на карьере установлен замок.", ["PlaceTimeLimit"] = "Этот объект уже нельзя удалить: истекло время на удаление.", ["AlreadyDestroyed"] = "Объект уже уничтожен.", ["TimeLeft"] = "Осталось: {0} с", ["GuiTitle"] = "РЕЖИМ УДАЛЕНИЯ", ["GuiAdminTitle"] = "АДМИНСКОЕ УДАЛЕНИЕ", ["GuiAllTitle"] = "УДАЛЕНИЕ СТРОЕНИЯ", ["GuiHint"] = "Ударьте молотком по объекту для удаления", ["NoBuilding"] = "Этот объект не привязан к строению.", ["PieDefault"] = "Простой ремув", ["PieDefaultDesc"] = "Удаление своих объектов с возвратом ресурсов", ["PieAdmin"] = "Админский ремув", ["PieAdminDesc"] = "Удаление любых объектов без проверок владения", ["PieAll"] = "Удаление строения", ["PieAllDesc"] = "Удалить все связанные объекты строения", ["PieClos
// ... [Code preview truncated due to length. Full source in attached file] ...