using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries; // v1.3.0:webrequest 的 RequestMethod 枚举在这个命名空间下 using Oxide.Core.Plugins; using Oxide.Game.Rust.Cui; using UnityEngine; /* * StackMaster v1.3.1 — 作者:薯片 * =========================================================================== * 中文堆叠管理面板插件(自研定制版) * * 功能:把社区 StackSizeController 与 StackFuelGenerator 合并为一个中文插件: * 1. 物品堆叠上限控制:三层配置(全局倍率 / 分类倍率 / 单物品精确堆叠) * 2. 发电机燃料容量:扫全图 FuelGenerator 改容器上限,热改即时生效,无需重启 * 3. 游戏内中文 GUI 面板(四页):使用说明 / 常用物品 / 分类倍率 / 发电机燃料 * 数值交互全部用「+/- 步进 + 快捷档位按钮」(游戏内文本输入不可靠) * 4. 权限:服务器 Owner / 管理员自动可用;他人需授权 stackmaster.admin * * 命令: * /stack 或 /stackmaster 打开管理面板(需权限) * /stack set <短名|ID> <数量|Nx> 精确设置单个物品堆叠 * /stack cat <分类> <倍率> 设置分类倍率 * /stack global <倍率> 设置全局倍率 * /stack fuel <容量> 设置发电机燃料容量 * /stack reset <短名> 恢复该物品为倍率计算值 * /stack list [分类] 文本方式查看当前配置(RCON 友好) * * 授权他人:oxide.grant user stackmaster.admin * * 插件授权(v1.3.0 新增,与插件后台「④ 插件授权码」打通): * 1. 到插件后台生成/复制授权码(PL-XXXX-XXXX-XXXX),一个码按购买台数绑定服务器 * 2. 激活二选一: * · 配置「授权 Authorize → 插件授权码 LicenseCode」填码后 oxide.reload StackMaster * · 控制台 / RCON 执行:stackmaster.auth PL-XXXX-XXXX-XXXX * 3. 授权成功后写入 oxide/data/StackMaster.auth.json,3 天内断网也能用(每 6 小时复核一次,联网自动续期) * 4. 没有有效授权码时本插件【完全不生效】:不改物品堆叠、不改发电机容量、面板不给设值 * 5. 启动时比对后台登记的最新版本,落后只提示不拦截(面板底栏 + 控制台) * * 配置:oxide/config/StackMaster.json(改完 oxide.reload StackMaster) * 数据:oxide/data/StackMaster_vanilla-defaults.json(原版堆叠基准表,可离线) * =========================================================================== */ namespace Oxide.Plugins { [Info("堆叠管理 StackMaster", "薯片", "1.3.1")] [Description("中文堆叠管理:物品堆叠上限 / 分类倍率 / 发电机燃料容量,服主与授权玩家 GUI 面板")] public class StackMaster : RustPlugin { // ===================================================================== #region 常量与字段 // ===================================================================== private const string PermissionAdmin = "stackmaster.admin"; private const string PanelName = "stackmaster.panel"; private const string DataFile = "StackMaster_vanilla-defaults"; // ---- 授权相关常量(v1.3.0)---- private const string AuthApiBase = "http://lic.biubr.com"; // 授权服务地址,换服只改这一处 private const string AuthPluginId = "stackmaster"; // 必须与后台插件清单的 plugin_id 一致 private const string AuthCacheFile = "StackMaster.auth"; // -> oxide/data/StackMaster.auth.json private const float AuthRetrySeconds = 300f; // 未授权时每 5 分钟自动重试 private const float AuthRenewHours = 6f; // ★ v1.3.1:常驻复核间隔,每 6h 主动问一次服务器 private const float PanelW = 760f; // v1.2.0 由 720 加宽,中文字号上调后排得开 private const float PanelH = 620f; // v1.2.0 由 560 加高 private const int TabCount = 4; // 0 使用说明 / 1 常用物品 / 2 分类倍率 / 3 发电机燃料 // ---- 统一配色表(暗蓝灰主题 + 青蓝数字,v1.2.0 全盘统一)---- private const string ColBg = "0.071 0.078 0.122 0.98"; // 面板底 private const string ColBar = "0.020 0.031 0.059 0.96"; // 标题栏 / 底栏 private const string ColCard = "0.102 0.118 0.173 1"; // 普通行 private const string ColCardOn = "0.137 0.157 0.212 1"; // 已改过的行 private const string ColTabOn = "0.220 0.451 0.659 1"; // 页签选中 private const string ColTabOff = "0.145 0.165 0.227 1"; // 页签未选 / 普通按钮 private const string ColText = "0.929 0.949 0.969 1"; // 主文字 private const string ColTextDim = "0.596 0.635 0.702 1"; // 次要文字 private const string ColTextOff = "0.450 0.480 0.550 1"; // 弱化文字 private const string ColNum = "0.400 0.851 0.910 1"; // 当前堆叠数字(青蓝) private const string ColMinus = "0.451 0.200 0.180 0.95"; // 减少按钮 private const string ColMinusTx = "0.960 0.810 0.790 1"; private const string ColPlus = "0.180 0.420 0.259 0.95"; // 增加按钮 private const string ColPlusTx = "0.790 0.960 0.810 1"; private const string ColNeutral = "0.290 0.322 0.400 1"; // 还原 / 中性 private const string ColStripeOn = "0.910 0.710 0.388 1"; // 左侧色条:已改过 private const string ColStripeOff = "0.212 0.235 0.310 1"; // 左侧色条:没改过 private const string ColClose = "0.350 0.150 0.150 0.95"; private const string ColCloseTx = "0.910 0.640 0.640 1"; private const string ColOk = "0.220 0.450 0.360 1"; private const string ColOkTx = "0.800 1.000 0.850 1"; private const string ColPanelIn = "0.055 0.067 0.106 0.95"; // 内嵌面板底 // 禁止改堆叠的物品(易造成异常/破坏玩法) private readonly HashSet _ignoreList = new HashSet { "water", "water.salt", "cardtable", "hat.bunnyhat", "rustige_egg_e" }; private ConfigData _config; private Dictionary _vanillaDefaults; // ---- 授权运行时状态(v1.3.0)---- private bool _authorized; // 当前是否已授权(false 时插件完全不生效) private bool _authPending; // 正在联网校验中,避免重复请求 private bool _unloaded; // 已卸载,异步回调直接丢弃 private AuthCache _authCache; // 本地授权缓存 private string _authHint = "尚未校验"; // 面板上显示的授权状态说明 private string _latestVersion = ""; // 后台登记的最新版本 private string _updateHint = ""; // 「有新版本」提醒文案(只提醒不拦) private readonly Dictionary _uiStates = new Dictionary(); // ===================================================================== #endregion // ===================================================================== #region 配置 // ===================================================================== private class ConfigData { // v1.2.1:配置版本位,用于判定「一次性升级」,当前版本 = 2。 // 旧配置里没有这个键 → 反序列化后保持初始值 1 → 触发一次清理; // 全新安装由 LoadDefaultConfig 直接写成 2(没有旧数据要清)。 // ⚠ 以后再加「只能跑一次」的升级逻辑,把这里 +1 并在 MigrateConfigKeys 里比对。 [JsonProperty("配置版本 ConfigVersion")] public int ConfigVersion = 1; [JsonProperty("通用设置 Settings")] public SettingsData Settings = new SettingsData(); [JsonProperty("全局堆叠倍率 GlobalStackMultiplier")] public float GlobalStackMultiplier = 1f; [JsonProperty("分类堆叠倍率 CategoryStackMultipliers")] public Dictionary CategoryStackMultipliers = DefaultCategoryMultipliers(); [JsonProperty("单物品精确堆叠上限 ItemStackLimits")] public Dictionary ItemStackLimits = new Dictionary(); [JsonProperty("发电机燃料容量 FuelGeneratorCapacity")] public int FuelGeneratorCapacity = 5000; [JsonProperty("设置燃料容量时同步燃料堆叠 SyncFuelItemStack")] public bool SyncFuelItemStack = true; // 兼容旧键名(v1.2.0 及以前写作 “...同步 lowgrade.fuel 堆叠...”): // 只有 setter 没有 getter,反序列化时能读进来,序列化时不会重复写出 [JsonProperty("设置燃料容量时同步 lowgrade.fuel 堆叠 SyncFuelItemStack")] private bool SyncFuelItemStackLegacy { set { SyncFuelItemStack = value; } } [JsonProperty("面板常用物品 CommonItems (shortname)")] public List CommonItems = DefaultCommonItems(); // v1.3.0:插件授权(码 + 离线宽限 + 版本提醒) [JsonProperty("授权 Authorize")] public AuthData Authorize = new AuthData(); } // v1.3.0 授权配置。授权码留空 = 未授权(插件完全不生效)。 private class AuthData { [JsonProperty("插件授权码 LicenseCode")] public string LicenseCode = ""; [JsonProperty("离线宽限天数 OfflineGraceDays")] public int OfflineGraceDays = 3; // ★ v1.3.1:7 -> 3,只在「连不上服务器」时才启用 [JsonProperty("启动时检查新版本 CheckUpdate")] public bool CheckUpdate = true; } private class SettingsData { [JsonProperty("卸载时恢复原版堆叠 RevertOnUnload")] public bool RevertOnUnload = true; [JsonProperty("允许带耐久物品堆叠 AllowDurabilityStack")] public bool AllowDurabilityStack = true; [JsonProperty("提示消息前缀 ChatPrefix")] public bool ChatPrefix = true; } // v1.2.1(B 方案):不再预填全部 14 个分类,配置里只会有「用户真正调过的分类」。 // 旧版把每一类都填上 1,导致判断「这类设过没有」永远成立,全局倍率被彻底压死。 // 现在没调过的分类在配置里根本不存在 → 自动跟随全局倍率。 private static Dictionary DefaultCategoryMultipliers() { return new Dictionary(); } private static List DefaultCommonItems() { return new List { "wood", "stones", "metal.ore", "sulfur.ore", "metal.fragments", "metal.refined", "scrap", "cloth", "leather", "rope", "lowgradefuel", "gunpowder", "charcoal", "bone.fragments", "animal.fat", "paper", "ammo.rifle", "ammo.pistol", "ammo.shotgun", "medical.syringe" }; } protected override void LoadConfig() { base.LoadConfig(); try { _config = Config.ReadObject(); if (_config == null) throw new Exception("null"); if (_config.Settings == null) _config.Settings = new SettingsData(); if (_config.CategoryStackMultipliers == null) _config.CategoryStackMultipliers = DefaultCategoryMultipliers(); if (_config.ItemStackLimits == null) _config.ItemStackLimits = new Dictionary(); if (_config.Authorize == null) _config.Authorize = new AuthData(); // 去重:Oxide ReadObject 对已初始化的 List 字段会追加而非替换,多次加载会累积重复项 if (_config.CommonItems == null || _config.CommonItems.Count == 0) _config.CommonItems = DefaultCommonItems(); else _config.CommonItems = _config.CommonItems.Distinct().ToList(); // v1.2.1:不再补齐分类。缺失的分类 = 用户没调过 = 跟随全局倍率。 // 修正历史遗留的错误短名(Rust 里低品级燃料的真实短名是 lowgradefuel) for (int i = 0; i < _config.CommonItems.Count; i++) if (_config.CommonItems[i] == "lowgrade.fuel") _config.CommonItems[i] = "lowgradefuel"; } catch (Exception ex) { PrintWarning($"配置文件无效({ex.Message}),已重建默认配置"); _config = new ConfigData(); _config.ConfigVersion = 2; // 已重建为最新结构,无需再清旧数据 } SaveConfig(); } protected override void LoadDefaultConfig() { // Oxide 首次加载(无 config 文件时)会调用这里而非 LoadConfig,必须也生成默认配置 _config = new ConfigData(); _config.ConfigVersion = 2; // 全新安装就是最新结构,不触发旧配置清理 SaveConfig(); } protected override void SaveConfig() => Config.WriteObject(_config, true); // v1.2.0 迁移:把旧配置里的英文 key 一次性换成中文 key(幂等,重复执行无副作用) // CategoryStackMultipliers: "Construction" -> "建造" // ItemStackLimits: "wood" / 英文原名 -> "木头" // 必须在 OnServerInitialized 阶段调用:LoadConfig 阶段 ItemManager 还没就绪 private void MigrateConfigKeys() { if (_config == null) return; bool changed = false; // 1) 分类倍率:英文枚举名 -> 中文名 if (_config.CategoryStackMultipliers != null) { foreach (var key in _config.CategoryStackMultipliers.Keys.ToList()) { if (string.IsNullOrEmpty(key)) continue; string cn; if (!CategoryCnNames.TryGetValue(key, out cn) || cn == key) continue; float v = _config.CategoryStackMultipliers[key]; _config.CategoryStackMultipliers.Remove(key); if (!_config.CategoryStackMultipliers.ContainsKey(cn)) _config.CategoryStackMultipliers[cn] = v; changed = true; } } // 2) 单物品精确堆叠上限:短名 / 英文原名 -> 中文名 if (_config.ItemStackLimits != null && _config.ItemStackLimits.Count > 0) { var migrated = new Dictionary(); foreach (var kv in _config.ItemStackLimits) { var def = FindItem(kv.Key); string newKey = def != null ? ItemName(def) : kv.Key; if (newKey != kv.Key) changed = true; if (!migrated.ContainsKey(newKey)) migrated[newKey] = kv.Value; } _config.ItemStackLimits = migrated; } // 3) v1.2.1 一次性升级:旧版本会把 14 个分类全部预填成 1, // 导致「这类调过没有」永远成立、全局倍率被彻底压死。 // 这里把「值为 ×1 的分类」删掉 = 改回跟随全局倍率。 // ⚠ 只能跑一次:否则玩家后来在新面板里故意设的「×1」会被每次重启都删掉。 bool fromOldConfig = _config.ConfigVersion < 2; var validCats = new HashSet(); foreach (var name in Enum.GetNames(typeof(ItemCategory))) validCats.Add(CategoryName(name)); foreach (var key in _config.CategoryStackMultipliers.Keys.ToList()) { // 历史噪音项(「全部 / 通用 / 搜索 / 收藏」不是 ItemCategory),任何时候都清掉 if (!validCats.Contains(key)) { _config.CategoryStackMultipliers.Remove(key); changed = true; continue; } if (fromOldConfig && Mathf.Abs(_config.CategoryStackMultipliers[key] - 1f) <= 0.001f) { _config.CategoryStackMultipliers.Remove(key); changed = true; } } if (_config.ConfigVersion != 2) { _config.ConfigVersion = 2; changed = true; } if (changed) { SaveConfig(); if (fromOldConfig) Puts("配置已升级到 v1.2.1:英文 key 已转中文,分类倍率只保留你调过的项,其余跟随全局倍率。"); else Puts("配置已更新。"); } } // ===================================================================== #endregion // ===================================================================== #region 生命周期 // ===================================================================== private void OnServerInitialized() { // 权限在 Init 阶段可能尚未就绪(会 NRE),统一放到服务器初始化完成后注册 permission.RegisterPermission(PermissionAdmin, this); // 兜底:若 Oxide 首次加载走了 LoadDefaultConfig 而未调 LoadConfig,_config 可能为 null if (_config == null) { LoadConfig(); if (_config == null) { _config = new ConfigData(); SaveConfig(); } } LoadVanillaDefaults(); // 旧版配置里的英文 key(分类枚举名 / 物品短名)在这里一次性转成中文名 MigrateConfigKeys(); // v1.3.0:授权判定放在最前。没授权就一步都不改堆叠 —— 功能完全不生效。 // 本地凭据有效时同步放行(不阻塞服务器启动);无效则异步去后台校验。 InitAuthorize(); if (_authorized) ApplyAll(); // 只提醒不拦:落后于后台登记版本就在控制台 + 面板底栏提示 CheckPluginVersion(); } private void Unload() { _unloaded = true; // 异步回调(联网校验 / 版本查询)到此直接丢弃 foreach (var p in BasePlayer.activePlayerList) CuiHelper.DestroyUi(p, PanelName); _uiStates.Clear(); if (_config != null && _config.Settings.RevertOnUnload && _vanillaDefaults != null && _vanillaDefaults.Count > 0) RevertStackSizes(); } private void OnPlayerDisconnected(BasePlayer player, string reason) { if (player != null) { _uiStates.Remove(player.userID); CuiHelper.DestroyUi(player, PanelName); } } // ===================================================================== #endregion // ===================================================================== #region 原版基准表(本地优先,免外网) // ===================================================================== private void LoadVanillaDefaults() { // 1) 本地 data 文件优先(部署时随插件上传,离线可用) try { var file = Interface.Oxide.DataFileSystem.GetFile(DataFile); if (file != null && file.Exists()) { var local = file.ReadObject>(); if (local != null && local.Count > 0) { _vanillaDefaults = local; Puts("原版堆叠基准表已从本地 data 文件加载。"); return; } } } catch (Exception ex) { PrintWarning($"读取本地基准表失败: {ex.Message}"); } // 2) 兜底:从当前 ItemManager 扫描生成(首次加载、尚未改堆叠时 = 原版值) PrintWarning("未找到本地原版基准表,正在从当前物品定义扫描生成(须在未修改堆叠的状态下执行)..."); var scan = new Dictionary(); foreach (var def in ItemManager.GetItemDefinitions()) { if (def == null || string.IsNullOrEmpty(def.shortname)) continue; if (scan.ContainsKey(def.shortname)) continue; scan[def.shortname] = def.stackable; } if (scan.Count > 0) { _vanillaDefaults = scan; try { Interface.Oxide.DataFileSystem.WriteObject(DataFile, scan); Puts($"基准表已自动生成并保存({scan.Count} 项)。"); } catch (Exception ex) { PrintWarning($"基准表保存失败: {ex.Message}"); } } else { PrintWarning("基准表生成失败,插件将按当前堆叠值运行。"); } } // ===================================================================== #endregion // ===================================================================== #region 核心计算与生效 // ===================================================================== private int GetVanillaStackSize(ItemDefinition def) { if (def == null) return 1; if (_vanillaDefaults != null && _vanillaDefaults.ContainsKey(def.shortname)) return _vanillaDefaults[def.shortname]; return Mathf.Max(1, def.stackable); } // 最终生效堆叠值(含全部层级),面板与 hook 统一走这里 private int GetEffectiveStackSize(ItemDefinition def) { if (def == null) return 1; if (_config == null) return GetVanillaStackSize(def); if (_ignoreList.Contains(def.shortname)) return GetVanillaStackSize(def); // 注意:ItemDefinition.Condition 是 struct(不可判空),无耐久物品 enabled=false if (def.condition.enabled && !_config.Settings.AllowDurabilityStack) return GetVanillaStackSize(def); int vanilla = GetVanillaStackSize(def); // 1) 单物品精确堆叠(最高优先级,GUI 步进/设值走这里) // v1.2.0 起配置 key 是中文物品名,统一用 ItemName() 取值 int limit; if (_config.ItemStackLimits.TryGetValue(ItemName(def), out limit) && limit > 0) return Mathf.Clamp(limit, 1, int.MaxValue); // 2) 分类倍率(未单独设置分类时默认 1 = 走全局);配置 key 是中文分类名 float mult = _config.GlobalStackMultiplier; float cat; if (_config.CategoryStackMultipliers.TryGetValue(CategoryName(def.category.ToString()), out cat)) mult = cat; return Mathf.Clamp(Mathf.RoundToInt(vanilla * mult), 1, int.MaxValue); } // 把当前配置写进所有 ItemDefinition(内存即时生效,无需重启) private void ApplyAll() { SetStackSizes(); ApplyFuelCapacity(); } private void SetStackSizes() { if (_vanillaDefaults == null) return; foreach (var def in ItemManager.GetItemDefinitions()) { if (def == null || string.IsNullOrEmpty(def.shortname)) continue; if (_ignoreList.Contains(def.shortname)) continue; def.stackable = GetEffectiveStackSize(def); } } private void RevertStackSizes() { Puts("正在把全部物品堆叠恢复为原版基准..."); foreach (var def in ItemManager.GetItemDefinitions()) { if (def == null || string.IsNullOrEmpty(def.shortname)) continue; def.stackable = GetVanillaStackSize(def); } } // 发电机燃料容量:全图重扫(热改即时生效,解决老发电机需重启的问题) private void ApplyFuelCapacity() { int cap = Mathf.Max(1, _config.FuelGeneratorCapacity); int applied = 0; try { #pragma warning disable CS0618 // ★ 这里【故意】继续用 FindObjectsOfType,不换成 Unity 建议的 FindObjectsByType: // FindObjectsByType 是 Unity 2023.1 才加的,买家的服务器未必更新到这么新的 Unity, // 一旦对方 Unity 老一点,插件会在对方服上直接【编译失败 = 整支不加载】。 // 而这里只在「插件加载 / 改配置热重载」时跑一次,不是每帧热路径, // 所以宁可留着旧写法、只把这条过时报警关掉,也不能拿买家的服冒崩的风险。 foreach (var gen in UnityEngine.Object.FindObjectsOfType()) #pragma warning restore CS0618 { if (gen == null || gen.inventory == null) continue; gen.inventory.maxStackSize = cap; applied++; } } catch (Exception ex) { PrintWarning($"刷新发电机失败: {ex.Message}"); } if (applied > 0) Puts($"已刷新 {applied} 台发电机的燃料容量为 {cap}。"); } // ===================================================================== #endregion // ===================================================================== #region Hooks // ===================================================================== // 物品尝试堆叠时实时兜底(配合 SetStackSizes 双保险) private int OnMaxStackable(Item item) { if (item == null || item.info == null) return 1; if (!_authorized) return item.info.stackable; // 未授权:完全不碰堆叠 if (_vanillaDefaults == null) return item.info.stackable; return GetEffectiveStackSize(item.info); } // 新生成的发电机自动应用燃料容量 private void OnEntitySpawned(FuelGenerator entity) { if (!_authorized) return; // 未授权:不碰发电机容量 if (entity == null || entity.inventory == null || _config == null) return; entity.inventory.maxStackSize = Mathf.Max(1, _config.FuelGeneratorCapacity); } // ===================================================================== #endregion // ===================================================================== // ===================================================================== // v1.3.1 授权撤销改造(★ 全项目统一标准,以后新建插件照这个来) // · ★ 加【常驻复核】:每 6 小时主动联网问一次。旧版只在 OnServerInitialized // 判断一次,且距上次成功不足 24h 连请求都不发 -> 作者在后台停用/删码之后, // 插件靠本地宽限能一直跑到过期,撤销最长要等 7 天,等于撤不掉。 // · 离线宽限 7 -> 3 天,且【只在「连不上服务器」时生效】;服务端一旦【明确拒绝】 // (码不存在 / 已停用 / 不是本插件的码 / 台数已满)→ 当场锁,一秒宽限都不给。 // · 服务端明确拒绝时抹掉本地凭据的 last_ok(Code 保留)→ 码恢复正常能自动复活。 // · CacheFresh 加两项:「配置里的码 ≠ 本地凭据的码 -> 旧凭据作废」+ 防系统时间回拨 // (当前时间比上次成功早 6h 以上 = 有人在拨表 -> 不认宽限,强制联网复核)。 // · 新增 stackmaster.deauth 注销命令(清本地凭据 + 清 config 里的码,立刻回未授权)。 // ===================================================================== #region 授权(插件授权码 · 联网校验 · 离线宽限) // ===================================================================== // ---- 本地授权缓存:oxide/data/StackMaster.auth.json ---- private class AuthCache { [JsonProperty("code")] public string Code = ""; [JsonProperty("plugin_id")] public string PluginId = ""; [JsonProperty("bound_at")] public string BoundAt = ""; [JsonProperty("last_ok")] public string LastOk = ""; // UTC,宽限期从这里起算 [JsonProperty("plugin_ver")] public string PluginVer = ""; } // ---- /api/plugin/verify 返回体(服务端多给的字段自动忽略)---- private class AuthResp { [JsonProperty("ok")] public bool ok; [JsonProperty("error")] public string error; [JsonProperty("msg")] public string msg; [JsonProperty("plugin")] public AuthPluginInfo plugin; } private class AuthPluginInfo { [JsonProperty("plugin_id")] public string plugin_id; [JsonProperty("name_zh")] public string name_zh; [JsonProperty("version")] public string version; } // ★ 离线宽限【硬上限】:配置里写得再多,也最多按 3 天算。 // 为什么必须夹: // ① 老版本写进买家 config 的是 7 天(v1.3.1 起默认值改成 3),而 config 里 // 躺着的值会赢 —— 收紧宽限这条安全策略对老用户压根没落到地上。 // ② 以前这里是 Mathf.Clamp(d, 0, 365),买家改成 365 就能离线白用一整年。 private const int AuthMaxGraceDays = 3; private int GraceDays { get { int d = (_config != null && _config.Authorize != null) ? _config.Authorize.OfflineGraceDays : 3; return Mathf.Clamp(d, 0, AuthMaxGraceDays); } } private static bool TryParseUtc(string s, out DateTime t) { t = DateTime.MinValue; if (string.IsNullOrEmpty(s)) return false; return DateTime.TryParse(s, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out t); } private static string MaskCode(string code) { if (string.IsNullOrEmpty(code)) return "(未填写)"; if (code.Length < 12) return code; return code.Substring(0, 7) + "-****-" + code.Substring(code.Length - 4); } private void LoadAuthCache() { try { var f = Interface.Oxide.DataFileSystem.GetFile(AuthCacheFile); _authCache = (f != null && f.Exists()) ? f.ReadObject() : null; } catch (Exception ex) { PrintWarning("授权缓存读取失败(忽略,稍后重新联网校验): " + ex.Message); _authCache = null; } if (_authCache == null) _authCache = new AuthCache(); } private void SaveAuthCache() { try { Interface.Oxide.DataFileSystem.WriteObject(AuthCacheFile, _authCache); } catch (Exception ex) { PrintWarning("授权缓存保存失败: " + ex.Message); } } // 本地凭据是否还在宽限期内(这就是「服务器断网也能继续用」的依据) private bool CacheFresh() { DateTime t; if (_authCache == null) return false; if (_authCache.PluginId != AuthPluginId) return false; if (!TryParseUtc(_authCache.LastOk, out t)) return false; // ★ 配置里写了另一个码 -> 旧凭据立刻作废(换了码不许旧凭据继续生效) string cfgCode = (_config != null && _config.Authorize != null && _config.Authorize.LicenseCode != null) ? _config.Authorize.LicenseCode.Trim().ToUpperInvariant() : ""; string cacheCode = (_authCache.Code != null) ? _authCache.Code.Trim().ToUpperInvariant() : ""; if (cfgCode.Length > 0 && !string.Equals(cfgCode, cacheCode, StringComparison.OrdinalIgnoreCase)) return false; // ★ 防系统时间回拨 —— 当前时间比上次成功校验还早 6h 以上 = 有人在拨表, // 这时【不认宽限】,强制联网复核(连不上就在 5 分钟重试里自愈)。 if (DateTime.UtcNow < t.AddHours(-6)) return false; return (DateTime.UtcNow - t).TotalDays <= GraceDays; } private string GraceExpireText() { DateTime t; if (_authCache == null || !TryParseUtc(_authCache.LastOk, out t)) return "(无)"; return t.ToLocalTime().AddDays(GraceDays) .ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture); } // 配置里的码优先,其次用上次校验成功的码 private string CurrentCode() { string c = (_config != null && _config.Authorize != null && _config.Authorize.LicenseCode != null) ? _config.Authorize.LicenseCode.Trim() : ""; if (c.Length > 0) return c.ToUpperInvariant(); return (_authCache != null && _authCache.Code != null) ? _authCache.Code.Trim().ToUpperInvariant() : ""; } // ---- 入口:OnServerInitialized 里调用 ---- private void InitAuthorize() { LoadAuthCache(); timer.Every(AuthRenewHours * 3600f, AuthRenew); // ★ v1.3.1:常驻复核,撤销最长 6h 生效 if (CacheFresh()) { _authorized = true; _authHint = "已授权(本地凭据有效)"; Puts("[授权] 已授权,本地凭据有效期至 " + GraceExpireText() + "(其间断网也能正常使用)。"); DateTime last; if (!TryParseUtc(_authCache.LastOk, out last) || (DateTime.UtcNow - last).TotalHours >= AuthRenewHours) RequestVerify(null, null, false); // 启动时距上次成功已超 6h,先续一次 return; } _authorized = false; if (string.IsNullOrEmpty(CurrentCode())) { _authHint = "未填写授权码"; PrintWarning("本插件尚未授权,当前不会改动任何物品堆叠 / 发电机容量。"); PrintWarning("激活方法:配置文件「授权 Authorize → 插件授权码 LicenseCode」填入 PL- 开头的码,"); PrintWarning("或在服务器控制台执行:stackmaster.auth <你的授权码>,然后 oxide.reload StackMaster。"); PrintAuthStatus(); return; // 没码就不必空转重试,等服主填码后重载 } _authHint = "正在联网校验…"; RequestVerify(null, null, false); } private void RetryAuth() { if (_unloaded || _authorized) return; if (string.IsNullOrEmpty(CurrentCode())) return; RequestVerify(null, null, false); } // ★ 常驻复核。已授权状态也必须定期问服务器一次 —— // 否则作者在后台「停用」或删掉授权码之后,插件靠本地宽限能一直跑到过期, // 等于撤销不了(旧版只在 OnServerInitialized 判一次,且距上次成功不足 24h // 连请求都不发 -> 重启插件 / 重启游戏服 / 删 config 三招全无效)。 private void AuthRenew() { if (_unloaded) return; if (string.IsNullOrEmpty(CurrentCode())) return; RequestVerify(null, null, false); } // ---- 联网校验 ---- // code 传空 = 用现有码续期;manual 非空 = 服主刚手动填码,要回显结果 private void RequestVerify(string code, BasePlayer manual, bool manualInput) { if (_unloaded) return; if (_authPending) { if (manual != null) Msg(manual, "正在校验中,请稍候…"); return; } string use = string.IsNullOrEmpty(code) ? CurrentCode() : code.Trim().ToUpperInvariant(); if (string.IsNullOrEmpty(use)) { if (manual != null) Msg(manual, "还没有授权码。用法:/stack auth PL-XXXX-XXXX-XXXX"); return; } if (webrequest == null) { OnAuthFailed(manual, "插件运行环境不支持联网(webrequest 不可用)", false, manualInput, use); return; } // 故意不上报服务器 IP:IP 一律由授权服务按请求来源判定,杜绝伪造 var payload = new Dictionary { { "code", use }, { "plugin_id", AuthPluginId } }; var headers = new Dictionary { { "Content-Type", "application/json" } }; _authPending = true; Puts("[授权] 正在向授权服务器校验(" + MaskCode(use) + ")…"); webrequest.Enqueue(AuthApiBase + "/api/plugin/verify", JsonConvert.SerializeObject(payload), (status, response) => OnVerifyResponse(status, response, use, manual, manualInput), this, RequestMethod.POST, headers, 15f); } private void OnVerifyResponse(int status, string response, string code, BasePlayer manual, bool manualInput) { _authPending = false; if (_unloaded) return; if (status != 200 || string.IsNullOrEmpty(response)) { // 网络类问题:宽限期内继续放行,绝不能因为一次抖动把买家服务器搞停 OnAuthFailed(manual, status == 0 ? "连不上授权服务器(出网被拦 / 服务临时不可用)" : ("授权服务器返回 HTTP " + status), false, manualInput, code); return; } AuthResp r = null; try { r = JsonConvert.DeserializeObject(response); } catch (Exception) { OnAuthFailed(manual, "授权服务器返回内容无法解析", false, manualInput, code); return; } if (r == null || !r.ok) { // 服务端明确拒绝(码不存在 / 已停用 / 不属于本插件 / 台数已满) string why = (r != null && !string.IsNullOrEmpty(r.error)) ? r.error : "授权校验未通过"; OnAuthFailed(manual, why, true, manualInput, code); return; } // ---- 校验通过 ---- _authorized = true; _authCache.Code = code; _authCache.PluginId = AuthPluginId; _authCache.LastOk = DateTime.UtcNow.ToString("o", System.Globalization.CultureInfo.InvariantCulture); _authCache.PluginVer = Version.ToString(); if (string.IsNullOrEmpty(_authCache.BoundAt)) _authCache.BoundAt = _authCache.LastOk; if (r.plugin != null && !string.IsNullOrEmpty(r.plugin.version)) _latestVersion = r.plugin.version; SaveAuthCache(); if (!string.IsNullOrEmpty(_latestVersion) && CompareVer(_latestVersion, Version.ToString()) > 0) SetUpdateHint(_latestVersion); _authHint = "已授权"; Puts("[授权] 校验通过,插件已生效。授权码 " + MaskCode(code) + ",本地凭据有效期至 " + GraceExpireText() + "。"); ApplyAll(); PrintAuthStatus(); if (manualInput && manual != null) Msg(manual, "授权成功,插件已生效(堆叠设置已应用)。"); } // serverRejected=true = 服务端明确拒绝,不接受离线宽限 // manualInput=true = 服主刚手填的码 // attempted = 这一次拿去校验的码,用来判断要不要回退 private void OnAuthFailed(BasePlayer manual, string reason, bool serverRejected, bool manualInput, string attempted) { if (_unloaded) return; // 情形一:这次用的码没通过,但本地还存着另一份仍然有效的凭据 // -> 自动把配置改回那个有效码并重新校验一次。 // 作用:服主在 config 里手抖打错一个字符,不会把原本正常的服务器搞停。 // 只有「两个码不一样」才回退,所以作者主动停用授权码时不会被绕过。 if (serverRejected && CacheFresh() && !string.IsNullOrEmpty(_authCache.Code) && !string.Equals(_authCache.Code, attempted, StringComparison.OrdinalIgnoreCase)) { string good = _authCache.Code; _config.Authorize.LicenseCode = good; SaveConfig(); PrintWarning("[授权] 这次用的码没通过(" + reason + "),已自动改回原来有效的授权码 " + MaskCode(good) + " 并重新校验。"); if (manual != null) Msg(manual, "这个码没通过(" + reason + "),已改回原来的授权码并重新校验…"); RequestVerify(good, null, false); return; } // 情形二:网络类问题 + 还在宽限期 -> 继续放行(买家服务器不能因为一次抖动停摆) if (!serverRejected && CacheFresh()) { _authorized = true; _authHint = "已授权(离线宽限中)"; PrintWarning("[授权] 本次联网校验没成功(" + reason + "),但本地凭据仍在 " + GraceDays + " 天宽限期内,插件继续正常工作。"); ApplyAll(); if (manual != null) Msg(manual, "联网校验没成功(" + reason + "),但本地凭据仍在宽限期内,功能不受影响。"); timer.Once(AuthRetrySeconds, RetryAuth); return; } // 情形三:真没授权 -> 功能不生效 // ★ 服务端【明确拒绝】时抹掉本地凭据的 last_ok(Code / PluginId 保留)—— // 这样「作者停用或删码」之后本地宽限立刻失效;码一旦恢复正常又能在 5 分钟重试里自动复活。 // 注意:网络类失败(serverRejected=false)绝不清凭据,不能因为一次抖动把买家搞停。 if (serverRejected && _authCache != null && !string.IsNullOrEmpty(_authCache.LastOk)) { _authCache.LastOk = ""; SaveAuthCache(); Puts("[授权] 已清除本地凭据(服务端拒绝:\"" + reason + "\"),宽限期不再生效。"); } _authorized = false; _authHint = serverRejected ? ("授权失败:" + reason) : ("暂未授权:" + reason); PrintWarning("[授权] 未通过:" + reason); PrintWarning("请确认授权码格式为 PL-XXXX-XXXX-XXXX 且买的是本插件;重新校验:控制台执行 stackmaster.auth <码>。"); if (_vanillaDefaults != null && _vanillaDefaults.Count > 0) RevertStackSizes(); if (manual != null) Msg(manual, "授权失败:" + reason); timer.Once(AuthRetrySeconds, RetryAuth); } private void PrintAuthStatus() { Puts("===== 堆叠管理 StackMaster 授权状态 ====="); Puts(" 状态:" + (_authorized ? "已授权(功能正常)" : "未授权(功能不生效)")); Puts(" 授权码:" + MaskCode(CurrentCode())); Puts(" 插件版本:" + Version.ToString() + (string.IsNullOrEmpty(_latestVersion) ? "" : ("(后台最新 " + _latestVersion + ")"))); Puts(" 本地凭据:" + (CacheFresh() ? ("有效,至 " + GraceExpireText()) : "无 / 已过期")); Puts(" 主动复核:每 " + AuthRenewHours + " 小时一次;仅「连不上服务器」时才用 " + GraceDays + " 天宽限"); Puts(" 用法:stackmaster.auth (不带参数 = 只看状态)"); Puts(" stackmaster.deauth(★ 注销:清本地凭据 + 清配置里的码,立刻回到未授权)"); } // ---- 版本比较(x.y.z 逐段比,段数不同按补 0)---- private static int CompareVer(string a, string b) { if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return 0; string[] pa = a.Split('.'), pb = b.Split('.'); int n = Math.Max(pa.Length, pb.Length); for (int i = 0; i < n; i++) { int x = i < pa.Length ? ParseSeg(pa[i]) : 0; int y = i < pb.Length ? ParseSeg(pb[i]) : 0; if (x != y) return x > y ? 1 : -1; } return 0; } private static int ParseSeg(string s) { int v; return (s != null && int.TryParse(s.Trim(), out v)) ? v : 0; } // ---- 只提醒不拦:查后台登记的版本 ---- private void CheckPluginVersion() { if (_unloaded) return; if (_config == null || _config.Authorize == null || !_config.Authorize.CheckUpdate) return; if (webrequest == null) return; webrequest.Enqueue(AuthApiBase + "/api/plugin/info/" + AuthPluginId, null, (status, response) => { if (_unloaded || status != 200 || string.IsNullOrEmpty(response)) return; try { AuthResp r = JsonConvert.DeserializeObject(response); if (r == null || !r.ok || r.plugin == null || string.IsNullOrEmpty(r.plugin.version)) return; _latestVersion = r.plugin.version; if (CompareVer(_latestVersion, Version.ToString()) > 0) SetUpdateHint(_latestVersion); } catch (Exception) { /* 查不到就静默,绝不拦 */ } }, this, RequestMethod.GET, null, 15f); } private void SetUpdateHint(string latest) { _updateHint = "有新版本 v" + latest + ",可到插件后台下载"; PrintWarning("[版本] 后台登记最新版为 v" + latest + ",你当前是 v" + Version.ToString() + "。功能不受影响,方便时更新即可。"); } // ===================================================================== #endregion // ===================================================================== #region 权限辅助 // ===================================================================== private bool CanUse(BasePlayer player) { if (player == null) return true; // console/RCON 视为管理员 return player.IsAdmin || permission.UserHasPermission(player.UserIDString, PermissionAdmin); } private void Msg(BasePlayer player, string text) { if (player == null) { Puts(text); return; } if (_config != null && _config.Settings.ChatPrefix) SendReply(player, "[堆叠管理] " + text); else SendReply(player, text); } // ===================================================================== #endregion // ===================================================================== #region 物品/分类中文名 // ===================================================================== private static readonly Dictionary ItemCnNames = new Dictionary { {"wood", "木头"}, {"stones", "石头"}, {"metal.ore", "金属矿石"}, {"sulfur.ore", "硫磺矿石"}, {"metal.fragments", "金属碎料"}, {"metal.refined", "金属块"}, {"scrap", "废料"}, {"cloth", "布料"}, {"leather", "皮革"}, {"rope", "绳索"}, {"lowgradefuel", "低品级燃料"}, {"gunpowder", "火药"}, {"charcoal", "木炭"}, {"bone.fragments", "骨头碎片"}, {"animal.fat", "动物脂肪"}, {"paper", "纸张"}, {"crude.oil", "原油"}, {"diesel_barrel", "柴油桶"}, {"ammo.pistol", "手枪子弹"}, {"ammo.pistol.fire", "燃烧手枪弹"}, {"ammo.pistol.hv", "高速手枪弹"}, {"ammo.rifle", "步枪子弹"}, {"ammo.rifle.hv", "高速步枪弹"}, {"ammo.rifle.incendiary", "燃烧步枪弹"}, {"ammo.rifle.explosive", "爆炸步枪弹"}, {"ammo.shotgun", "霰弹"}, {"ammo.shotgun.fire", "燃烧霰弹"}, {"ammo.shotgun.slug", "独头弹"}, {"ammo.handmade.shell", "手制霰弹"}, {"ammo.nailgun.nails", "射钉枪钉"}, {"ammo.grenadelauncher.he", "榴弹发射器高爆弹"}, {"ammo.rocket.basic", "火箭弹"}, {"ammo.rocket.fire", "燃烧火箭弹"}, {"ammo.rocket.hv", "高速火箭弹"}, {"ammo.rocket.smoke", "烟雾火箭弹"}, {"arrow.wooden", "木箭"}, {"arrow.bone", "骨箭"}, {"arrow.fire", "火箭"}, {"arrow.hv", "高速箭"}, {"arrow.stone", "石箭"}, {"explosive.satchel", "定时炸药包"}, {"explosive.timed", "定时炸弹"}, {"grenade.f1", "F1 手雷"}, {"grenade.smoke", "烟雾弹"}, {"grenade.flashbang", "闪光弹"}, {"survey.charge", "勘测炸药"}, {"bandage", "绷带"}, {"medical.syringe", "医疗针"}, {"antiradpills", "抗辐射药"}, {"large.medkit", "大医疗包"}, {"small.medkit", "小医疗包"}, {"hammer", "锤子"}, {"hatchet", "石斧"}, {"axe.salvaged", "废料斧"}, {"pickaxe", "石镐"}, {"pickaxe.salvaged", "废料镐"}, {"torch", "火把"}, {"flashlight", "手电筒"}, {"building.planner", "建筑蓝图"}, {"wire.tool", "电工钳"}, {"wooden.door", "木门"}, {"sheetmetal.door", "铁门"}, {"garagedoor", "车库门"}, {"code.lock", "密码锁"}, {"key.lock", "钥匙锁"}, {"metal.fence", "铁丝网"}, {"codelockedhackcrate", "密码箱"}, {"locker", "储物柜"}, {"box.wooden", "木箱"}, {"furnace", "熔炉"}, {"furnace.large", "大熔炉"}, {"campfire", "篝火"}, {"barbeque", "烧烤架"}, {"repair.bench", "维修台"}, {"research.table", "研究台"}, {"workbench1", "工作台1级"}, {"workbench2", "工作台2级"}, {"workbench3", "工作台3级"}, {"refinery", "精炼机"}, {"recycler", "回收机"}, {"vending.machine", "自动售货机"}, {"lantern", "提灯"}, {"ceiling.light", "吸顶灯"}, {"door.hinged.wood", "木铰链门"}, {"floor.ladder.hatch", "梯井盖"}, {"ladder.wooden.wall", "木梯"}, {"trap.bear", "捕熊夹"}, {"trap.landmine", "地雷"}, {"trap.spikes", "木刺"}, {"trap.wire", "电铁丝"}, {"electric.solarpanel.large", "大型太阳能板"}, {"battery.small", "小电池"}, {"battery.medium", "中电池"}, {"battery.large", "大电池"}, {"generator.small", "发电机"}, {"electric.fuelgenerator", "燃油发电机"}, {"teslacoil", "特斯拉线圈"}, {"target.reactive", "反应靶"}, {"switch", "开关"}, {"electric.button", "按钮"}, {"pressurepad", "压力板"}, {"timer", "定时器"}, {"electric.heater", "电暖器"}, {"rf.receiver", "遥控接收器"}, {"rf.transmitter", "遥控发射器"}, {"powerbank.small", "小型充电宝"}, {"electrical.combiner", "电源合并器"}, {"electrical.splitter", "电源分线器"}, {"hoodie", "连帽衫"}, {"jacket", "夹克"}, {"pants", "长裤"}, {"tshirt", "T恤"}, {"shirt.collared", "衬衫"}, {"burlap.shirt", "麻布衣"}, {"burlap.pants", "麻布裤"}, {"burlap.shoes", "麻布鞋"}, {"wood.armor.jacket", "木甲衣"}, {"wood.armor.pants", "木甲裤"}, {"roadsign.jacket", "路牌甲衣"}, {"roadsign.pants", "路牌甲裤"}, {"metal.plate.torso", "金属板甲"}, {"metal.plate.pants", "金属板裤"}, {"hide.skin", "兽皮"}, {"hazmatsuit", "防化服"}, {"riot.helmet", "防暴头盔"}, {"metal.facemask", "金属面罩"}, {"coffeecan.helmet", "咖啡罐头盔"}, {"bucket.helmet", "水桶头盔"}, {"sunglasses", "墨镜"}, {"gloves.tactical", "战术手套"}, {"boots.frog", "蛙人鞋"}, {"mask.balaclava", "巴拉克拉瓦面罩"}, {"can.beans", "豆子罐头"}, {"can.tuna", "金枪鱼罐头"}, {"canned.food", "罐头食品"}, {"chicken.cooked", "熟鸡肉"}, {"chicken.raw", "生鸡肉"}, {"bearmeat.cooked", "熟熊肉"}, {"bearmeat", "熊肉"}, {"wolfmeat.cooked", "熟狼肉"}, {"wolfmeat.raw", "生狼肉"}, {"pork.cooked", "熟猪肉"}, {"pork.raw", "生猪排"}, {"meat.boar", "野猪肉"}, {"deermeat.cooked", "熟鹿肉"}, {"deermeat.raw", "生鹿肉"}, {"fish.cooked", "熟鱼"}, {"fish.raw", "生鱼"}, {"apple", "苹果"}, {"blueberries", "蓝莓"}, {"mushroom", "蘑菇"}, {"potato", "土豆"}, {"corn", "玉米"}, {"chocholate", "巧克力"}, {"granolabar", "燕麦棒"}, {"jar.pickle", "腌菜罐"}, {"smallwaterbottle", "小水瓶"}, {"bota.bag", "水囊"}, {"skull.human", "人类头骨"}, {"skull.wolf", "狼头骨"}, {"bone.club", "骨棒"}, {"spear.wooden", "木矛"}, {"spear.stone", "石矛"}, {"knife.bone", "骨刀"}, {"salvaged.sword", "废料剑"}, {"rifle.ak", "AK-47"}, {"rifle.bolt", "栓动步枪"}, {"smg.thompson", "汤普森冲锋枪"}, {"smg.mp5", "MP5"}, {"shotgun.pump", "泵动霰弹枪"}, {"shotgun.double", "双管霰弹枪"}, {"pistol.revolver", "左轮手枪"}, {"pistol.semiauto", "半自动手枪"}, {"pistol.python", "蟒蛇左轮"}, {"pistol.m92", "M92 手枪"}, {"lmg.m249", "M249"}, {"lmg.hmlmg", "HMLMG"}, {"weapon.mod.silencer", "消音器"}, {"weapon.mod.flashlight", "武器手电"}, {"weapon.mod.lasersight", "激光瞄准器"}, {"weapon.mod.holosight", "全息瞄具"}, {"weapon.mod.small.scope", "小型瞄准镜"}, {"weapon.mod.8x.scope", "8倍镜"}, {"weapon.mod.muzzlebrake", "枪口制退器"}, {"weapon.mod.extendedmags", "加长弹匣"}, {"riflebody", "步枪枪身"}, {"smgbody", "冲锋枪枪身"}, {"shotgunbody", "霰弹枪枪身"}, {"pistolbody", "手枪枪身"}, {"semibody", "半自动枪身"}, {"metalpipe", "金属管"}, {"gears", "齿轮"}, {"spring", "弹簧"}, {"sheet.metal", "金属板"}, {"metalblade", "金属刀刃"}, {"metalspring", "金属弹簧"}, {"sewingkit", "针线包"}, {"techparts", "科技零件"}, {"cctv.camera", "监控摄像头"}, {"targeting.computer", "瞄准计算机"}, {"rf.detonator", "遥控引爆器"}, {"explosives", "炸药"}, {"fungi", "真菌"}, {"halloween.surgeonsuit", "手术服"}, // ---- v1.2.0 补充:资源 / 材料 ---- {"sulfur", "硫磺"}, {"hq.metal.ore", "高金矿石"}, {"tarp", "防水布"}, {"ducttape", "胶带"}, {"fertilizer", "肥料"}, {"water", "水"}, {"water.salt", "盐水"}, {"blood", "血包"}, {"wrappedgift", "包装礼物"}, {"horsedung", "马粪"}, {"seed.hemp", "大麻种子"}, {"seed.corn", "玉米种子"}, {"seed.pumpkin", "南瓜种子"}, {"seed.sunflower", "向日葵种子"}, {"clone.hemp", "大麻基因"}, {"clone.corn", "玉米基因"}, {"clone.pumpkin", "南瓜基因"}, {"pumpkin", "南瓜"}, {"sunflower", "向日葵"}, {"wheat", "小麦"}, {"hay", "干草"}, // ---- 食物 ---- {"fish.minnows", "小鱼"}, {"worm", "蚯蚓"}, {"grub", "幼虫"}, {"horsemeat.raw", "生马肉"}, {"horsemeat.cooked", "熟马肉"}, {"humanmeat.raw", "生人肉"}, {"humanmeat.cooked", "熟人肉"}, {"cactusflesh", "仙人掌肉"}, {"can.chicken", "鸡肉罐头"}, // ---- 茶饮 / 药品 ---- {"healthtea", "治疗茶"}, {"maxhealthtea", "生命上限茶"}, {"radiationremovetea", "去辐射茶"}, {"radiationresisttea", "抗辐射茶"}, {"oretea", "矿石茶"}, {"scraptea", "废料茶"}, {"woodtea", "木材茶"}, {"supertea", "超级茶"}, {"pureoretea", "纯矿茶"}, // ---- 弹药 ---- {"ammo.rocket.seeker", "追踪火箭弹"}, {"ammo.grenadelauncher.buckshot", "榴弹霰弹"}, {"ammo.grenadelauncher.smoke", "榴弹烟雾弹"}, {"mortar.shell", "迫击炮弹"}, {"mortar.shell.smoke", "迫击炮烟雾弹"}, {"grenade.beancan", "豆罐手雷"}, {"grenade.molotov", "燃烧瓶"}, {"snowball", "雪球"}, {"ammo.snowballgun", "雪球弹药"}, // ---- 武器 / 工具 ---- {"jackhammer", "凿岩机"}, {"chainsaw", "电锯"}, {"machete", "砍刀"}, {"knife.combat", "战斗刀"}, {"multiplegrenade.launcher", "多发榴弹发射器"}, {"rocket.launcher", "火箭筒"}, {"homingmissile.launcher", "追踪导弹发射器"}, {"minigun", "加特林机枪"}, {"snowballgun", "雪球枪"}, {"paddle", "船桨"}, {"fishingrod", "钓竿"}, {"bucket.water", "水桶"}, {"megaphone", "扩音器"}, {"note", "便签"}, {"tool.binoculars", "望远镜"}, {"parachute", "降落伞"}, // ---- 建造 ---- {"door.hinged.metal", "铁铰链门"}, {"door.hinged.toptier", "高金铰链门"}, {"door.double.hinged.wood", "双开木门"}, {"door.double.hinged.metal", "双开铁门"}, {"door.double.hinged.toptier", "双开高金门"}, {"shutter.wood.a", "木百叶窗"}, {"wall.frame.shopfront", "售货窗框"}, {"wall.window.bars.wood", "木窗栅栏"}, {"wall.window.bars.metal", "铁窗栅栏"}, {"wall.window.bars.toptier", "高金窗栅栏"}, // ---- 电气 ---- {"electrical.branch", "电源支线"}, {"electrical.blocker", "电源阻断器"}, {"electrical.memorycell", "记忆电池"}, {"electrical.random.switch", "随机开关"}, {"electric.orswitch", "或开关"}, {"electric.xorswitch", "异或开关"}, {"electric.andswitch", "与开关"}, {"electric.igniter", "点火器"}, {"electric.hbhfsensor", "心跳传感器"}, {"electric.doorcontroller", "门控制器"}, {"electric.simplelight", "简易灯"}, {"electric.flasherlight", "闪烁灯"}, {"electric.sirenlight", "警示灯"}, {"electric.searchlight", "探照灯"}, {"electric.counter", "计数器"}, {"electric.windturbine", "风力发电机"}, {"electric.audioalarm", "声音警报器"}, {"electric.laserdetector", "激光探测器"}, {"electric.digitalclock", "数字时钟"}, {"samsite", "防空炮台"}, {"autoturret", "自动炮台"}, {"flameturret", "火焰炮台"}, // ---- 工业 ---- {"industrial.crafter", "工业制造机"}, {"industrial.conveyor", "工业传送带"}, {"industrial.splitter", "工业分线器"}, {"industrial.combiner", "工业合并器"}, {"industrial.storage", "工业储物箱"}, {"industrial.pipe", "工业管道"}, {"fluidswitch", "流体开关"}, {"mixingtable", "混合台"}, {"small.oil.rig", "小型抽油机"}, {"mining.quarry", "矿石开采机"}, {"mining.pumpjack", "抽油机"}, // ---- 潜水 / 骑乘 ---- {"diving.tank", "氧气瓶"}, {"diving.fins", "脚蹼"}, {"diving.mask", "潜水镜"}, {"wetsuit", "潜水服"}, {"horse.saddle", "马鞍"}, {"horse.saddlebag", "马鞍包"} }; private static readonly Dictionary CategoryCnNames = new Dictionary { {"Ammunition", "弹药"}, {"Attire", "服装"}, {"Component", "零件"}, {"Construction", "建造"}, {"Electrical", "电气"}, {"Food", "食物"}, {"Fun", "娱乐"}, {"Items", "物品"}, {"Resources", "资源"}, {"All", "全部"}, {"Common", "通用"}, {"Search", "搜索"}, {"Favourite", "收藏"}, {"Medical", "医疗"}, {"Misc", "杂项"}, {"Tool", "工具"}, {"Traps", "陷阱"}, {"Weapon", "武器"} }; // 显示名:中文名优先;没收录的退回英文原名(保证面板不会只剩短名) private static string ItemName(ItemDefinition def) { if (def == null) return "?"; string cn; if (ItemCnNames.TryGetValue(def.shortname, out cn)) return cn; return string.IsNullOrEmpty(def.displayName?.english) ? def.shortname : def.displayName.english; } // 分类显示名:枚举名 -> 中文;没收录的退回原枚举名 private static string CategoryName(string category) { if (string.IsNullOrEmpty(category)) return "?"; string cn; return CategoryCnNames.TryGetValue(category, out cn) ? cn : category; } // 中文分类名 -> 英文枚举名(聊天命令支持直接写中文分类,如 /stack cat 建造 2) private static string CatEnFromCn(string cn) { if (string.IsNullOrEmpty(cn)) return null; foreach (var kv in CategoryCnNames) if (kv.Value == cn) return kv.Key; return null; } // ===================================================================== #endregion // ===================================================================== #region 聊天命令 // ===================================================================== [ChatCommand("stack")] private void CmdStack(BasePlayer player, string command, string[] args) => HandleChatCommand(player, args); [ChatCommand("stackmaster")] private void CmdStackMaster(BasePlayer player, string command, string[] args) => HandleChatCommand(player, args); private void HandleChatCommand(BasePlayer player, string[] args) { if (!CanUse(player)) { Msg(player, "你没有权限使用此功能(需要服务器管理员或授权 stackmaster.admin)。"); return; } // 授权码命令:未授权时也必须能用(这是激活入口) if (args != null && args.Length > 0 && args[0].ToLowerInvariant() == "auth") { string code = args.Length > 1 ? args[1].Trim() : ""; if (string.IsNullOrEmpty(code)) { Msg(player, "当前授权状态:" + _authHint + " 授权码 " + MaskCode(CurrentCode()) + " (用法:/stack auth PL-XXXX-XXXX-XXXX 填码激活)"); return; } if (_config == null) _config = new ConfigData(); if (_config.Authorize == null) _config.Authorize = new AuthData(); _config.Authorize.LicenseCode = code.ToUpperInvariant(); SaveConfig(); Msg(player, "正在校验授权码,结果会在这里告诉你…"); RequestVerify(code, player, true); return; } if (args == null || args.Length == 0) { OpenPanel(player); return; } // 未授权:只保留打开面板(面板里是授权引导页),其余设置一律不执行 if (!_authorized) { Msg(player, "插件未授权,设置项不可用。请先激活:控制台执行 stackmaster.auth <你的授权码>"); OpenPanel(player); return; } switch (args[0].ToLowerInvariant()) { case "set": CmdSet(player, args.Skip(1).ToArray()); break; case "cat": CmdCat(player, args.Skip(1).ToArray()); break; case "global": CmdGlobal(player, args.Skip(1).ToArray()); break; case "fuel": CmdFuel(player, args.Skip(1).ToArray()); break; case "reset": CmdReset(player, args.Skip(1).ToArray()); break; case "list": CmdList(player, args.Skip(1).ToArray()); break; case "open": OpenPanel(player); break; default: Msg(player, "用法:/stack 开面板 | set <物品> <数量|Nx> | cat <分类> <倍率> | global <倍率> | fuel <容量> | reset <物品> | list | auth <授权码>"); break; } } private void CmdSet(BasePlayer player, string[] args) { if (args.Length < 2) { Msg(player, "用法:/stack set <短名|ID> <数量|Nx>(例:/stack set wood 5000 或 10x)"); return; } var def = FindItem(args[0]); if (def == null) { Msg(player, "找不到物品:" + args[0]); return; } int value; if (TryParseStackValue(args[1], def, out value)) { _config.ItemStackLimits[ItemName(def)] = value; SaveConfig(); ApplyAll(); Msg(player, $"已将 {ItemName(def)} ({def.shortname}) 堆叠上限设为 {value:N0}。"); } else Msg(player, "数量无效,需为整数或 Nx 倍率。"); } private void CmdCat(BasePlayer player, string[] args) { if (args.Length < 2) { Msg(player, "用法:/stack cat <分类> <倍率>(/stack list 查看分类名)"); return; } ItemCategory cat; string catKey; if (Enum.TryParse(args[0], true, out cat)) { catKey = CategoryName(cat.ToString()); } else if (CatEnFromCn(args[0]) != null) { catKey = args[0]; // 直接写中文分类名也认 } else { Msg(player, "分类无效:" + args[0] + "(可用:/stack list 查看,例:建造 / 资源 / 武器)"); return; } float mult; if (!float.TryParse(args[1].TrimEnd('x'), out mult) || mult <= 0) { Msg(player, "倍率需为正数(例:2 = 两倍,0.5 = 一半)。"); return; } _config.CategoryStackMultipliers[catKey] = mult; SaveConfig(); ApplyAll(); Msg(player, $"已把分类「{catKey}」堆叠倍率设为 ×{mult:0.##}。"); } private void CmdGlobal(BasePlayer player, string[] args) { if (args.Length < 1) { Msg(player, "用法:/stack global <倍率>"); return; } float mult; if (!float.TryParse(args[0].TrimEnd('x'), out mult) || mult <= 0) { Msg(player, "倍率需为正数(例:2 = 两倍,0.5 = 一半)。"); return; } _config.GlobalStackMultiplier = mult; SaveConfig(); ApplyAll(); Msg(player, $"已把全局堆叠倍率设为 ×{mult:0.##}。"); } private void CmdFuel(BasePlayer player, string[] args) { if (args.Length < 1) { Msg(player, "用法:/stack fuel <容量>"); return; } int cap; if (!int.TryParse(args[0], out cap) || cap < 1) { Msg(player, "容量需为正整数。"); return; } _config.FuelGeneratorCapacity = cap; if (_config.SyncFuelItemStack) { var syncDef = FindItem("lowgradefuel"); if (syncDef != null) _config.ItemStackLimits[ItemName(syncDef)] = cap; Puts("已同步把低品级燃料的堆叠上限设为与燃料容量一致。"); } SaveConfig(); ApplyAll(); Msg(player, $"发电机燃料容量已设为 {cap:N0},全图已刷新(无需重启)。"); } private void CmdReset(BasePlayer player, string[] args) { if (args.Length < 1) { Msg(player, "用法:/stack reset <短名>"); return; } var def = FindItem(args[0]); if (def == null) { Msg(player, "找不到物品:" + args[0]); return; } if (_config.ItemStackLimits.Remove(ItemName(def))) { SaveConfig(); ApplyAll(); } Msg(player, $"已移除 {ItemName(def)} 的自定义堆叠,恢复为倍率计算值(原版 {GetVanillaStackSize(def):N0})。"); } private void CmdList(BasePlayer player, string[] args) { // 支持英文枚举名或中文分类名筛选,也支持不传 ItemCategory? filter = null; if (args != null && args.Length > 0) { ItemCategory fc; if (Enum.TryParse(args[0], true, out fc)) filter = fc; else { string en = CatEnFromCn(args[0]); if (en != null && Enum.TryParse(en, true, out fc)) filter = fc; } } var sb = new System.Text.StringBuilder(); sb.AppendLine("=== 当前堆叠配置 ==="); sb.AppendLine("全局倍率:×" + _config.GlobalStackMultiplier.ToString("0.##")); sb.AppendLine("--- 分类倍率 ---"); foreach (var kv in _config.CategoryStackMultipliers) if (kv.Value > 0.001f) sb.AppendLine($" {kv.Key}:×{kv.Value:0.##}"); sb.AppendLine("--- 自定义物品 ---"); int shown = 0; foreach (var kv in _config.ItemStackLimits) { var def = FindItem(kv.Key); if (def == null) continue; if (filter.HasValue && def.category != filter.Value) continue; sb.AppendLine($" {kv.Key}:{kv.Value:N0}"); shown++; } if (shown == 0) sb.AppendLine(" (暂时没有单独设置过任何物品)"); sb.AppendLine("--- 发电机 ---"); sb.AppendLine($" 燃料容量:{_config.FuelGeneratorCapacity:N0} 同步燃料堆叠:{(_config.SyncFuelItemStack ? "开" : "关")}"); foreach (var line in sb.ToString().Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries)) Msg(player, line); } private ItemDefinition FindItem(string input) { if (string.IsNullOrEmpty(input)) return null; var def = ItemManager.FindItemDefinition(input); if (def != null) return def; int id; if (int.TryParse(input, out id)) return ItemManager.FindItemDefinition(id); return FindItemByCn(input); } // 中文物品名 -> ItemDefinition(用 ItemCnNames 反查) private static ItemDefinition FindItemByCn(string cn) { if (string.IsNullOrEmpty(cn)) return null; foreach (var kv in ItemCnNames) if (kv.Value == cn) return ItemManager.FindItemDefinition(kv.Key); // 兼容「没收录的物品用英文原名当配置 key」的情况 return ItemManager.FindItemDefinition(cn); } private bool TryParseStackValue(string raw, ItemDefinition def, out int value) { value = 0; if (string.IsNullOrEmpty(raw)) return false; if (raw.EndsWith("x") || raw.EndsWith("X")) { float mult; if (!float.TryParse(raw.TrimEnd('x', 'X'), out mult) || mult <= 0) return false; value = Mathf.RoundToInt(GetVanillaStackSize(def) * mult); return value >= 1; } return int.TryParse(raw, out value) && value >= 1; } // ===================================================================== #endregion // ===================================================================== #region GUI 面板 // ===================================================================== private class UiState { public int Tab; // 0 使用说明 / 1 常用物品 / 2 分类倍率 / 3 发电机燃料 public int Page; // 翻页 public string Selected; // 选中的物品短名(GUI 内部通信一律用短名,只有显示才用中文) } private UiState GetState(BasePlayer player) { UiState st; if (!_uiStates.TryGetValue(player.userID, out st)) { st = new UiState(); _uiStates[player.userID] = st; } return st; } private void OpenPanel(BasePlayer player) { if (player == null) return; if (!CanUse(player)) { Msg(player, "你没有权限打开堆叠管理面板。"); return; } DrawPanel(player); } // ===================================================================== // 主面板(760 x 620) // 标题栏 580..620 | 页签 526..572 | 内容 40..514 | 底栏 0..36 // ===================================================================== private void DrawPanel(BasePlayer player) { if (player == null || !player.IsConnected) return; CuiHelper.DestroyUi(player, PanelName); var st = GetState(player); if (st.Tab < 0 || st.Tab >= TabCount) st.Tab = 0; if (st.Page < 0) st.Page = 0; var c = new CuiElementContainer(); // ===== 主面板 ===== c.Add(new CuiPanel { Image = { Color = ColBg }, RectTransform = { AnchorMin = "0.5 0.5", AnchorMax = "0.5 0.5", OffsetMin = $"{(-PanelW / 2):0} {(-PanelH / 2):0}", OffsetMax = $"{(PanelW / 2):0} {(PanelH / 2):0}" }, CursorEnabled = true }, "Overlay", PanelName); // ===== 标题栏 ===== AddPanel(c, 0, 580, PanelW, 40, ColBar); AddLabel(c, 18, 586, 460, 614, "堆叠管理 · 服主面板", 15, TextAnchor.MiddleLeft, ColText); AddButton(c, "关闭", "stackmaster.gui close", 682, 586, 60, 28, ColClose, 13, ColCloseTx); if (!_authorized) { // 未授权:页签和全部设置都不显示,只给授权引导(功能完全不生效) DrawAuthBlock(c); } else { // ===== 页签 ===== string[] tabs = { "使用说明", "① 常用物品", "② 分类倍率", "③ 发电机燃料" }; for (int i = 0; i < TabCount; i++) DrawTabButton(c, tabs[i], i, st); // ===== 内容 ===== if (st.Tab == 0) DrawTabGuide(c, st); else if (st.Tab == 1) DrawTabItems(c, st); else if (st.Tab == 2) DrawTabCategories(c, st); else DrawTabFuel(c, st); } // ===== 底栏 ===== AddPanel(c, 0, 0, PanelW, 36, ColBar); string foot = _authorized ? ("全局倍率 ×" + _config.GlobalStackMultiplier.ToString("0.##") + "  发电机容量 " + _config.FuelGeneratorCapacity.ToString("N0") + "  改动即时生效,无需重启" + (string.IsNullOrEmpty(_updateHint) ? "" : ("  " + _updateHint))) : ("未授权 · 插件当前完全不生效 · " + _authHint); AddLabel(c, 18, 8, 742, 30, foot, 13, TextAnchor.MiddleLeft, ColTextDim); CuiHelper.AddUi(player, c); } // 未授权时的面板内容:只讲怎么激活,不给任何设置入口 private void DrawAuthBlock(CuiElementContainer c) { AddPanel(c, 18, 380, 724, 132, ColCard); AddLabel(c, 40, 476, 720, 502, "本插件尚未授权", 18, TextAnchor.MiddleLeft, ColStripeOn); AddLabel(c, 40, 446, 720, 470, "没有有效授权码时,插件不会改动任何物品堆叠与发电机容量,这里也不会显示设置页。", 13, TextAnchor.MiddleLeft, ColTextDim); AddLabel(c, 40, 420, 720, 442, "当前状态:" + _authHint, 13, TextAnchor.MiddleLeft, ColText); AddPanel(c, 18, 240, 724, 128, ColPanelIn); AddLabel(c, 40, 338, 720, 360, "三步激活", 15, TextAnchor.MiddleLeft, ColNum); AddLabel(c, 40, 310, 720, 332, "第 1 步 到插件后台「④ 插件授权码」生成或复制你的授权码(PL- 开头)", 13, TextAnchor.MiddleLeft, ColText); AddLabel(c, 40, 284, 720, 306, "第 2 步 服务器控制台(或 RCON / F1)输入:stackmaster.auth PL-XXXX-XXXX-XXXX", 13, TextAnchor.MiddleLeft, ColText); AddLabel(c, 40, 258, 720, 280, "第 3 步 看到「授权成功」后执行 oxide.reload StackMaster,功能即全部生效", 13, TextAnchor.MiddleLeft, ColText); AddPanel(c, 18, 60, 724, 168, ColCard); AddLabel(c, 40, 200, 720, 222, "常见问题", 15, TextAnchor.MiddleLeft, ColNum); AddLabel(c, 40, 174, 720, 196, "· 授权码也能填在配置文件「授权 Authorize → 插件授权码 LicenseCode」里,填完重载插件即可。", 12, TextAnchor.MiddleLeft, ColTextDim); AddLabel(c, 40, 150, 720, 172, "· 一个码能绑几台服务器按你购买时约定的台数(例如 2 台),换新服请联系作者解绑旧的那台。", 12, TextAnchor.MiddleLeft, ColTextDim); AddLabel(c, 40, 126, 720, 148, "· 激活成功后会记住在本机,之后 3 天内断网也能正常使用,联网时自动续期。", 12, TextAnchor.MiddleLeft, ColTextDim); AddLabel(c, 40, 102, 720, 124, "· 授权服务临时不通时,只要还在宽限期内插件照常工作,不会突然停服。", 12, TextAnchor.MiddleLeft, ColTextDim); AddLabel(c, 40, 78, 720, 100, "· 买了但码用不了?加作者微信 shupian1315,把服务器 IP 和授权码发过来。", 12, TextAnchor.MiddleLeft, ColTextDim); } private void DrawTabButton(CuiElementContainer c, string name, int tabIndex, UiState st) { const float w = 175f, gap = 8f; float x = 18 + tabIndex * (w + gap); bool active = st.Tab == tabIndex; AddButton(c, name, "stackmaster.gui tab " + tabIndex, x, 526, w, 46, active ? ColTabOn : ColTabOff, 15, active ? "1 1 1 1" : ColTextDim); } // --------------------------------------------------------------- // Tab0 使用说明(v1.2.0 新增,默认打开这一页) // --------------------------------------------------------------- private void DrawTabGuide(CuiElementContainer c, UiState st) { // 一句话总结 AddPanel(c, 18, 420, 724, 48, ColCard); AddLabel(c, 34, 442, 726, 466, "服主专用的堆叠设置面板", 15, TextAnchor.MiddleLeft, ColText); AddLabel(c, 34, 422, 726, 442, "想让哪种物品一次叠更多,就到下面页签改数字;改完立刻生效,不用重启服务器。", 13, TextAnchor.MiddleLeft, ColTextDim); // 三步就会用 AddLabel(c, 18, 388, 400, 410, "三步就会用", 14, TextAnchor.MiddleLeft, ColText); DrawGuideCard(c, 18, "第 1 步", "找到要改的物品", "打开「① 常用物品」页", "用左右按钮翻页找物品"); DrawGuideCard(c, 264, "第 2 步", "点按钮改数字", "红色是减少,绿色是增加", "中间青色大数字=现在上限"); DrawGuideCard(c, 510, "第 3 步", "改错了就还原", "点「还原」回到原版值", "点物品名可精确设置"); // 颜色怎么看 AddLabel(c, 18, 228, 400, 250, "颜色怎么看", 14, TextAnchor.MiddleLeft, ColText); DrawLegend(c, 18, ColMinus, "红色=减少"); DrawLegend(c, 164, ColPlus, "绿色=增加"); DrawLegend(c, 310, ColNeutral, "灰色=还原"); DrawLegend(c, 456, ColNum, "青色=当前值"); DrawLegend(c, 602, ColStripeOn, "亮条=已改过"); // 打字也能改 AddLabel(c, 18, 146, 460, 170, "打字也能改(聊天框输入)", 14, TextAnchor.MiddleLeft, ColText); AddLabel(c, 34, 118, 726, 140, "/stack set wood 10000  /stack global 5", 13, TextAnchor.MiddleLeft, ColTextDim); AddLabel(c, 34, 94, 726, 116, "/stack fuel 12000  /stack reset wood", 13, TextAnchor.MiddleLeft, ColTextDim); AddLabel(c, 34, 66, 726, 88, "命令里的物品可以写短名(木头=wood、石头=stones),也可以写中文名或数字 ID。", 12, TextAnchor.MiddleLeft, ColTextOff); } // 使用说明页的三张步骤卡 private void DrawGuideCard(CuiElementContainer c, float x, string step, string title, string line1, string line2) { const float w = 232f; AddPanel(c, x, 260, w, 122, ColCard); AddLabel(c, x + 16, 348, x + w - 16, 372, step, 13, TextAnchor.MiddleLeft, ColNum); AddLabel(c, x + 16, 312, x + w - 16, 340, title, 15, TextAnchor.MiddleLeft, ColText); AddLabel(c, x + 16, 286, x + w - 16, 308, line1, 12, TextAnchor.MiddleLeft, ColTextDim); AddLabel(c, x + 16, 264, x + w - 16, 286, line2, 12, TextAnchor.MiddleLeft, ColTextDim); } // 使用说明页的颜色图例 private void DrawLegend(CuiElementContainer c, float x, string color, string text) { AddPanel(c, x, 194, 16, 16, color); AddLabel(c, x + 24, 186, x + 136, 218, text, 12, TextAnchor.MiddleLeft, ColTextDim); } // --------------------------------------------------------------- // Tab1 常用物品 // --------------------------------------------------------------- private void DrawTabItems(CuiElementContainer c, UiState st) { var defs = new List(); foreach (var sn in _config.CommonItems) { var def = FindItem(sn); if (def != null) defs.Add(def); } defs = defs.GroupBy(x => x.shortname).Select(x => x.First()) .Where(x => !_ignoreList.Contains(x.shortname)).ToList(); const int pageSize = 5; int maxPage = Mathf.Max(0, (defs.Count - 1) / pageSize); if (st.Page > maxPage) st.Page = maxPage; int start = st.Page * pageSize; // ===== 页头 ===== bool hasPrev = st.Page > 0; bool hasNext = st.Page < maxPage; AddButton(c, "◀ 上一页", "stackmaster.gui page -1", 18, 478, 90, 34, hasPrev ? ColNeutral : ColTabOff, 13, hasPrev ? ColText : ColTextOff); AddLabel(c, 116, 480, 544, 510, "常用物品  第 " + (st.Page + 1) + " / " + (maxPage + 1) + " 页  共 " + defs.Count + " 种", 13, TextAnchor.MiddleCenter, ColTextDim); AddButton(c, "下一页 ▶", "stackmaster.gui page 1", 552, 478, 90, 34, hasNext ? ColNeutral : ColTabOff, 13, hasNext ? ColText : ColTextOff); AddButton(c, "+ 加物品", "stackmaster.gui custom", 650, 478, 92, 34, "0.200 0.320 0.260 1", 13, ColText); // ===== 物品行(5 行,每行 66 高、间隔 7,最底一行落在 112)===== const float rowTop = 470f, rowH = 66f, rowGap = 7f; for (int i = start; i < Mathf.Min(start + pageSize, defs.Count); i++) { var def = defs[i]; int cur = GetEffectiveStackSize(def); int vanilla = GetVanillaStackSize(def); string cnName = ItemName(def); bool hasCustom = _config.ItemStackLimits.ContainsKey(cnName); bool selected = st.Selected == def.shortname; float y = rowTop - (i - start) * (rowH + rowGap) - rowH; // 行卡片 + 左侧状态色条 AddPanel(c, 18, y, 724, rowH, selected ? "0.176 0.204 0.290 1" : (hasCustom ? ColCardOn : ColCard)); AddPanel(c, 18, y + 12, 3, rowH - 24, hasCustom ? ColStripeOn : ColStripeOff); // 物品名(可点,点一下选中并在底部出现精确档位) AddButton(c, cnName, "stackmaster.gui sel " + def.shortname, 30, y + 8, 300, 44, "0 0 0 0", 15, ColText, TextAnchor.MiddleLeft); AddLabel(c, 34, y + 44, 336, y + 60, "原版 " + vanilla.ToString("N0"), 11, TextAnchor.MiddleLeft, ColTextOff); // 当前堆叠上限(数字用青蓝,一行里最显眼) AddLabel(c, 336, y + 17, 452, y + 49, cur.ToString("N0"), 18, TextAnchor.MiddleCenter, ColNum); // 步进(红减 / 绿加)+ 还原 float bx = 458f; AddButton(c, "-1000", $"stackmaster.gui step {def.shortname} -1000", bx, y + 14, 52, 38, ColMinus, 13, ColMinusTx); bx += 58f; AddButton(c, "-100", $"stackmaster.gui step {def.shortname} -100", bx, y + 14, 52, 38, ColMinus, 13, ColMinusTx); bx += 58f; AddButton(c, "+100", $"stackmaster.gui step {def.shortname} 100", bx, y + 14, 52, 38, ColPlus, 13, ColPlusTx); bx += 58f; AddButton(c, "+1000", $"stackmaster.gui step {def.shortname} 1000", bx, y + 14, 52, 38, ColPlus, 13, ColPlusTx); bx += 58f; AddButton(c, "还原", $"stackmaster.gui reset {def.shortname}", bx, y + 14, 52, 38, ColNeutral, 13, ColText); } // ===== 底部精确档位区 ===== DrawPresetBar(c, st); } // 底部精确档位区(40..104):未选中物品时显示引导文字 private void DrawPresetBar(CuiElementContainer c, UiState st) { AddPanel(c, 18, 40, 724, 64, ColPanelIn); var selDef = string.IsNullOrEmpty(st.Selected) ? null : FindItem(st.Selected); if (selDef == null) { AddLabel(c, 34, 66, 726, 90, "点上面的物品名可以选中它,然后在这里按档位精确设置", 13, TextAnchor.MiddleLeft, ColTextDim); AddLabel(c, 34, 46, 726, 68, "也可以直接在聊天框输入:/stack set wood 10000", 12, TextAnchor.MiddleLeft, ColTextOff); return; } int curVal = GetEffectiveStackSize(selDef); int vanilla = GetVanillaStackSize(selDef); AddLabel(c, 34, 80, 726, 100, "已选:" + ItemName(selDef) + "  原版 " + vanilla.ToString("N0") + "  当前 " + curVal.ToString("N0"), 13, TextAnchor.MiddleLeft, ColText); float fx = 24f; int[] presets = { 500, 1000, 5000, 10000, 50000 }; foreach (var p in presets) { bool isCur = curVal == p; AddButton(c, p.ToString("N0"), $"stackmaster.gui preset {selDef.shortname} {p}", fx, 46, 92, 30, isCur ? ColOk : ColTabOff, 13, isCur ? ColOkTx : ColText); fx += 98f; } AddButton(c, "同原版", $"stackmaster.gui preset {selDef.shortname} {vanilla}", fx, 46, 84, 30, "0.200 0.240 0.360 1", 13, ColText); fx += 90f; AddButton(c, "还原原版", $"stackmaster.gui reset {selDef.shortname}", fx, 46, 84, 30, ColNeutral, 13, ColText); } // --------------------------------------------------------------- // Tab2 分类倍率 // --------------------------------------------------------------- private void DrawTabCategories(CuiElementContainer c, UiState st) { var cats = Enum.GetNames(typeof(ItemCategory)).ToList(); const int pageSize = 5; int maxPage = Mathf.Max(0, (cats.Count - 1) / pageSize); if (st.Page > maxPage) st.Page = maxPage; int start = st.Page * pageSize; // ===== 页头 ===== bool hasPrev = st.Page > 0; bool hasNext = st.Page < maxPage; AddButton(c, "◀ 上一页", "stackmaster.gui page -1", 18, 478, 90, 34, hasPrev ? ColNeutral : ColTabOff, 13, hasPrev ? ColText : ColTextOff); AddLabel(c, 116, 480, 596, 510, "分类倍率 第 " + (st.Page + 1) + " / " + (maxPage + 1) + " 页 · 点档位=单独设置,点「跟随」=改回用全局值", 12, TextAnchor.MiddleCenter, ColTextDim); AddButton(c, "下一页 ▶", "stackmaster.gui page 1", 604, 478, 90, 34, hasNext ? ColNeutral : ColTabOff, 13, hasNext ? ColText : ColTextOff); // ===== 右侧:全局倍率 ===== AddPanel(c, 612, 112, 130, 358, ColPanelIn); AddLabel(c, 622, 430, 732, 456, "全局倍率", 14, TextAnchor.MiddleCenter, ColText); AddLabel(c, 622, 386, 732, 428, "×" + _config.GlobalStackMultiplier.ToString("0.##"), 22, TextAnchor.MiddleCenter, ColNum); string[] gButtons = { "×0.5", "×1", "×2", "×3", "×5", "×10" }; string[] gCmds = { "0.5", "1", "2", "3", "5", "10" }; float gy = 344f; for (int gi = 0; gi < gButtons.Length; gi++) { bool isCurG = Mathf.Abs(_config.GlobalStackMultiplier - float.Parse(gCmds[gi])) < 0.001f; AddButton(c, gButtons[gi], "stackmaster.gui global " + gCmds[gi], 622, gy, 110, 34, isCurG ? ColTabOn : ColTabOff, 13, isCurG ? "1 1 1 1" : ColText); gy -= 40f; } AddLabel(c, 622, 130, 732, 150, "显示「跟随」的", 11, TextAnchor.MiddleCenter, ColTextOff); AddLabel(c, 622, 112, 732, 132, "分类就用这个值", 11, TextAnchor.MiddleCenter, ColTextOff); // ===== 分类行 ===== // 单独设置过(配置里有这个分类)→ 显示自己的倍率;没设过 → 显示「跟随 ×全局」 const float rowTop = 470f, rowH = 66f, rowGap = 7f; for (int i = start; i < Mathf.Min(start + pageSize, cats.Count); i++) { string cat = cats[i]; string catCn = CategoryName(cat); float own; bool custom = _config.CategoryStackMultipliers.TryGetValue(catCn, out own); float y = rowTop - (i - start) * (rowH + rowGap) - rowH; AddPanel(c, 18, y, 582, rowH, custom ? ColCardOn : ColCard); AddPanel(c, 18, y + 12, 3, rowH - 24, custom ? ColStripeOn : ColStripeOff); AddLabel(c, 34, y + 12, 170, y + 54, catCn, 15, TextAnchor.MiddleLeft, ColText); if (custom) AddLabel(c, 174, y + 17, 256, y + 49, "×" + own.ToString("0.##"), 18, TextAnchor.MiddleCenter, ColNum); else AddLabel(c, 174, y + 20, 256, y + 46, "跟随 ×" + _config.GlobalStackMultiplier.ToString("0.##"), 12, TextAnchor.MiddleCenter, ColTextOff); // 7 个按钮:跟随 / ×0.5 / ×1 / ×2 / ×3 / ×5 / ×10 float bx = 262f; AddButton(c, "跟随", "stackmaster.gui catfollow " + cat, bx, y + 14, 44, 38, !custom ? ColTabOn : ColTabOff, 12, !custom ? "1 1 1 1" : ColText); bx += 48f; float[] presets = { 0.5f, 1f, 2f, 3f, 5f, 10f }; foreach (var p in presets) { bool isCur = custom && Mathf.Abs(own - p) < 0.001f; AddButton(c, "×" + p.ToString("0.##"), $"stackmaster.gui catmult {cat} {p}", bx, y + 14, 44, 38, isCur ? ColTabOn : ColTabOff, 12, isCur ? "1 1 1 1" : ColText); bx += 48f; } } } // --------------------------------------------------------------- // Tab3 发电机燃料 // --------------------------------------------------------------- private void DrawTabFuel(CuiElementContainer c, UiState st) { int cap = _config.FuelGeneratorCapacity; var fuelDef = FindItem("lowgradefuel"); int fuelCur = fuelDef != null ? GetEffectiveStackSize(fuelDef) : 0; int fuelVanilla = fuelDef != null ? GetVanillaStackSize(fuelDef) : 0; // ===== 容量主显示 ===== AddPanel(c, 18, 386, 724, 84, ColCard); AddLabel(c, 40, 428, 300, 452, "发电机燃料容量", 15, TextAnchor.MiddleLeft, ColText); AddLabel(c, 40, 404, 330, 424, "原版 500 | 改完整图发电机会自动刷新", 12, TextAnchor.MiddleLeft, ColTextDim); AddLabel(c, 340, 396, 600, 460, cap.ToString("N0"), 32, TextAnchor.MiddleCenter, ColNum); AddLabel(c, 618, 428, 726, 452, "单格上限", 13, TextAnchor.MiddleRight, ColTextDim); // ===== 快捷档位 ===== int[] presets = { 1000, 2000, 5000, 10000, 20000, 50000 }; float bx = 18f; foreach (var p in presets) { bool isCur = cap == p; AddButton(c, p.ToString("N0"), "stackmaster.gui fuel " + p, bx, 316, 114, 52, isCur ? ColOk : ColTabOff, 15, isCur ? ColOkTx : ColText); bx += 122f; } // ===== 操作按钮 ===== AddButton(c, "自定义容量(聊天输入)", "stackmaster.gui fuelchat", 18, 250, 233, 46, "0.200 0.240 0.360 1", 13, ColText); AddButton(c, "同步燃料堆叠上限", "stackmaster.gui syncfuel", 263, 250, 233, 46, "0.200 0.420 0.300 0.95", 13, ColText); AddButton(c, "刷新全图发电机", "stackmaster.gui refreshgen", 508, 250, 233, 46, "0.240 0.280 0.440 1", 13, ColText); // ===== 规则说明 ===== AddPanel(c, 18, 112, 724, 124, ColPanelIn); AddLabel(c, 34, 210, 726, 234, "燃料叠加规则", 14, TextAnchor.MiddleLeft, ColText); AddLabel(c, 34, 184, 726, 204, "1. 发电机单格容量由上面的「燃料容量」控制,原版是 500。", 13, TextAnchor.MiddleLeft, ColTextDim); AddLabel(c, 34, 160, 726, 180, "2. 实际加注量还受「低品级燃料」自己的堆叠上限限制。", 13, TextAnchor.MiddleLeft, ColTextDim); AddLabel(c, 34, 136, 726, 156, "3. 容量设 5,000 时,低品级燃料堆叠也要 ≥5,000,点上面第二个按钮一键同步。", 13, TextAnchor.MiddleLeft, ColTextDim); AddLabel(c, 34, 114, 726, 132, "4. 当前:燃料容量 " + cap.ToString("N0") + " | 低品级燃料堆叠 " + fuelCur.ToString("N0") + "(原版 " + fuelVanilla.ToString("N0") + ")", 12, TextAnchor.MiddleLeft, ColNum); } // --------------------------------------------------------------- // GUI 小工具 // --------------------------------------------------------------- // 生成绝对定位的 RectTransform(父级为面板,原点在面板左下角) private static CuiRectTransformComponent Rt(float x1, float y1, float x2, float y2) { return new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = $"{x1:0} {y1:0}", OffsetMax = $"{x2:0} {y2:0}" }; } private void AddPanel(CuiElementContainer c, float x, float y, float w, float h, string color) { c.Add(new CuiPanel { Image = { Color = color }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = $"{x:0} {y:0}", OffsetMax = $"{(x + w):0} {(y + h):0}" } }, PanelName); } private void AddLabel(CuiElementContainer c, float x1, float y1, float x2, float y2, string text, int fontSize, TextAnchor align, string color) { c.Add(new CuiLabel { Text = { Text = text, FontSize = fontSize, Align = align, Color = color }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = $"{x1:0} {y1:0}", OffsetMax = $"{x2:0} {y2:0}" } }, PanelName); } private void AddButton(CuiElementContainer c, string text, string command, float x, float y, float w, float h, string color, int fontSize = 13, string textColor = "1 1 1 1", TextAnchor align = TextAnchor.MiddleCenter) { c.Add(new CuiButton { Button = { Command = command, Color = color }, Text = { Text = text, FontSize = fontSize, Align = align, Color = textColor }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = $"{x:0} {y:0}", OffsetMax = $"{(x + w):0} {(y + h):0}" } }, PanelName); } // ===================================================================== #endregion // ===================================================================== #region GUI Console 回调 // ===================================================================== [ConsoleCommand("stackmaster.gui")] private void CcGui(ConsoleSystem.Arg arg) { var player = arg?.Player(); if (player == null || !player.IsConnected) return; if (!CanUse(player)) { Msg(player, "你没有权限使用堆叠管理面板。"); return; } if (arg.Args == null || arg.Args.Length == 0) return; // 未授权:只允许关面板,其余点击一律拒绝(功能完全不生效) if (!_authorized) { if (arg.GetString(0).ToLowerInvariant() == "close") { CuiHelper.DestroyUi(player, PanelName); _uiStates.Remove(player.userID); } else Msg(player, "插件未授权,设置项不可用。请先激活:控制台执行 stackmaster.auth <你的授权码>"); return; } // 注意:ConsoleSystem.Arg.Args 的元素类型是 Facepunch.StringView(非 string), // 不能直接赋给 string / 调用 string 方法 / 用 ?? 判空; // 统一用 Arg.GetString(n) 官方 API 取值(内部处理 StringView,越界返回空串)。 // 另外:GUI 传参一律用短名 / 英文枚举名,只有显示才用中文,避免中文进 console 参数出岔子。 var st = GetState(player); string cmd = arg.GetString(0).ToLowerInvariant(); string a1 = arg.GetString(1); string a2 = arg.GetString(2); switch (cmd) { case "close": CuiHelper.DestroyUi(player, PanelName); _uiStates.Remove(player.userID); return; case "tab": { int tab; if (int.TryParse(a1, out tab)) { st.Tab = Mathf.Clamp(tab, 0, TabCount - 1); st.Page = 0; } break; } case "sel": // 点物品名:选中 / 再点一次取消(选中后底部出现精确档位) st.Selected = (st.Selected == a1) ? null : a1; break; case "page": { int d; if (int.TryParse(a1, out d)) st.Page = Mathf.Max(0, st.Page + d); break; } case "custom": Msg(player, "面板物品清单在配置文件的「面板常用物品 CommonItems」里,填物品短名即可;任意物品也能用 /stack set <短名|中文名|ID> <数量> 直接设置。"); break; case "fuelchat": Msg(player, "精确设置请用:/stack fuel <容量>(例:/stack fuel 12000)"); break; // 步进:把当前值 ±delta 写成精确堆叠 case "step": if (!string.IsNullOrEmpty(a2)) { var def = FindItem(a1); int delta; if (def != null && int.TryParse(a2, out delta)) { int cur = GetEffectiveStackSize(def); int val = Mathf.Clamp(cur + delta, 1, int.MaxValue); _config.ItemStackLimits[ItemName(def)] = val; SaveConfig(); ApplyAll(); st.Selected = def.shortname; Msg(player, ItemName(def) + " 堆叠:" + cur.ToString("N0") + " → " + val.ToString("N0")); } } break; case "preset": if (!string.IsNullOrEmpty(a2)) { var def = FindItem(a1); int val; if (def != null && int.TryParse(a2, out val) && val >= 1) { _config.ItemStackLimits[ItemName(def)] = val; SaveConfig(); ApplyAll(); st.Selected = def.shortname; Msg(player, ItemName(def) + " 堆叠已设为 " + val.ToString("N0") + "。"); } } break; case "reset": if (!string.IsNullOrEmpty(a1)) { var def = FindItem(a1); if (def != null) { _config.ItemStackLimits.Remove(ItemName(def)); SaveConfig(); ApplyAll(); st.Selected = def.shortname; Msg(player, ItemName(def) + " 已还原(按倍率算现在是 " + GetEffectiveStackSize(def).ToString("N0") + ")。"); } } break; // 恢复「跟随全局倍率」:把该分类从配置里删掉 case "catfollow": { string fcat = a1; if (Enum.GetNames(typeof(ItemCategory)).Contains(fcat)) { string fcn = CategoryName(fcat); if (_config.CategoryStackMultipliers.Remove(fcn)) { SaveConfig(); ApplyAll(); Msg(player, "分类「" + fcn + "」已恢复为跟随全局倍率 ×" + _config.GlobalStackMultiplier.ToString("0.##") + "。"); } } break; } case "catmult": if (!string.IsNullOrEmpty(a2)) { float mult; if (float.TryParse(a2, out mult) && mult > 0) { string cat = a1; if (Enum.GetNames(typeof(ItemCategory)).Contains(cat)) { string catCn = CategoryName(cat); _config.CategoryStackMultipliers[catCn] = mult; SaveConfig(); ApplyAll(); Msg(player, "分类「" + catCn + "」倍率已设为 ×" + mult.ToString("0.##") + "。"); } } } break; case "global": { float gm; if (float.TryParse(a1, out gm) && gm > 0) { _config.GlobalStackMultiplier = gm; SaveConfig(); ApplyAll(); Msg(player, "全局堆叠倍率已设为 ×" + gm.ToString("0.##") + "。"); } break; } case "fuel": { int cap; if (int.TryParse(a1, out cap) && cap >= 1) { _config.FuelGeneratorCapacity = cap; if (_config.SyncFuelItemStack) { var fd = FindItem("lowgradefuel"); if (fd != null) _config.ItemStackLimits[ItemName(fd)] = cap; } SaveConfig(); ApplyAll(); Msg(player, "发电机燃料容量已设为 " + cap.ToString("N0") + "。"); } break; } case "syncfuel": { int c2 = _config.FuelGeneratorCapacity; var fd = FindItem("lowgradefuel"); if (fd != null) _config.ItemStackLimits[ItemName(fd)] = c2; SaveConfig(); ApplyAll(); Msg(player, "已把低品级燃料的堆叠上限同步为 " + c2.ToString("N0") + "。"); break; } case "refreshgen": ApplyFuelCapacity(); Msg(player, "全图发电机已刷新。"); break; } DrawPanel(player); } // 控制台 / RCON 授权入口:stackmaster.auth [授权码],不带参数只看状态 [ConsoleCommand("stackmaster.deauth")] private void CcDeauth(ConsoleSystem.Arg arg) { var player = arg != null ? arg.Player() : null; // RCON / 服务器控制台 -> null if (player != null && !player.IsAdmin) { Msg(player, "只有服务器管理员能注销授权。"); return; } _authorized = false; _authHint = "已注销(请重新填授权码)"; _authCache = new AuthCache(); SaveAuthCache(); if (_config != null && _config.Authorize != null) { _config.Authorize.LicenseCode = ""; SaveConfig(); Puts("[授权] 配置里的授权码也已清空。"); } foreach (var p in BasePlayer.activePlayerList) if (p != null && p.IsConnected) CuiHelper.DestroyUi(p, PanelName); Puts("[授权] 已注销:本地凭据与配置里的授权码都已清除,插件回到未授权状态。"); Puts(" 重新激活:stackmaster.auth PL-XXXX-XXXX-XXXX"); if (player != null) Msg(player, "已注销,插件回到未授权状态。"); } [ConsoleCommand("stackmaster.auth")] private void CcAuth(ConsoleSystem.Arg arg) { var player = arg != null ? arg.Player() : null; // RCON / 服务器控制台 -> null if (player != null && !CanUse(player)) { Puts("你没有权限执行该命令。"); return; } string code = (arg != null && arg.Args != null && arg.Args.Length > 0) ? arg.GetString(0).Trim() : ""; if (string.IsNullOrEmpty(code)) { PrintAuthStatus(); if (player != null) Msg(player, "当前授权状态:" + _authHint + " 授权码 " + MaskCode(CurrentCode())); return; } if (_config == null) _config = new ConfigData(); if (_config.Authorize == null) _config.Authorize = new AuthData(); _config.Authorize.LicenseCode = code.ToUpperInvariant(); SaveConfig(); Puts("[授权] 已写入配置,开始校验 " + MaskCode(code) + " …"); RequestVerify(code, player, player != null); } // ===================================================================== #endregion } }