// 体质强化 TZqianghua v1.2.0(纯 GUI:无容器拖拽,含上限覆盖 HUD;1.2.0 接入首尔授权) // 1.1.2 审查修复:采集掉落判定重写 / 洗点降级后水饿值收缩防白嫖 / 受伤HUD刷新节流 / 配置健壮性 // 拖拽强化台 + 自绘HUD + 采集掉落 + 祝福石 + 重置药剂 + 一键合成 // 设计定稿 2026-10-16(薯片):20级x3线 | 温和失败 | 祝福值保底 | 新手保护 | 混搭拖拽 using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries; // webrequest 的 RequestMethod 枚举在这个命名空间下(授权联网校验用) using Oxide.Core.Plugins; using Oxide.Game.Rust.Cui; using UnityEngine; namespace Oxide.Plugins { [Info("体质强化 TZqianghua", "薯片", "1.2.0")] [Description("宝石强化体质:生命/水分/饱食三条成长线,拖拽强化台+自绘HUD")] public class TZqianghua : RustPlugin { #region // ===== 常量 ===== // 跨插件引用 private Plugin ImageLibrary; private readonly HashSet _iconReady = new HashSet(); // 已成功灌入 FileStorage 的图标键 private string _iconNone = ""; // ImageLibrary 的 NONE 占位 id,用于判空 private const string UI_MAIN = "TZQ_Main"; // 按钮模式面板 private const string UI_HOVER = "TZQ_Hover"; // 悬浮小按钮 private const string UI_HUD = "TZQ_HUD"; // 自绘HUD private const string CMD = "TZQ.cmd"; private const string C_BG = "0.086 0.094 0.114 0.94"; private const string C_ROW = "0.118 0.129 0.157 1"; private const string C_TITLE = "0.055 0.063 0.075 1"; private const string C_SUB = "0.227 0.247 0.290 1"; private const string C_SLOT = "0 0 0 0.5"; private const string C_ORANGE = "0.941 0.494 0.235 1"; private const string C_GOLD = "0.941 0.753 0.235 1"; private const string C_GRAY = "0.604 0.627 0.667 1"; private const string C_WHITE = "0.910 0.918 0.930 1"; private const string C_HP = "0.427 0.831 0.494 1"; private const string C_WATER = "0.310 0.639 0.890 1"; private const string C_FOOD = "0.941 0.627 0.235 1"; private const string C_RED = "0.886 0.341 0.298 1"; private const string C_HI = "0 0 0 0.55"; private const string SFX_OK = "assets/prefabs/deployable/research table/effects/research-success.prefab"; private const string SFX_FAIL = "assets/bundled/prefabs/fx/item_break.prefab"; private const string FONT_B = "robotocondensed-bold.ttf"; private const string FONT_R = "robotocondensed-regular.ttf"; private static readonly string[] TierColor = { C_HP, C_WATER, C_RED, C_GOLD }; #endregion #region // ===== CUI 构造辅助(bottom-based 坐标:y=元素底边距父底边像素)===== private static void SetRect(CuiRectTransformComponent r, int x, int yBottom, int w, int h) { r.AnchorMin = "0 0"; r.AnchorMax = "0 0"; r.OffsetMin = $"{x} {yBottom}"; r.OffsetMax = $"{x + w} {yBottom + h}"; } private static CuiPanel NewPanel(string color, int x, int yBottom, int w, int h) { var p = new CuiPanel { Image = { Color = color } }; SetRect(p.RectTransform, x, yBottom, w, h); return p; } private static CuiLabel NewLabel(string text, int size, string color, TextAnchor align, bool bold, int x, int yBottom, int w, int h) { var l = new CuiLabel { Text = { Text = text, FontSize = size, Font = bold ? FONT_B : FONT_R, Align = align, Color = color } }; SetRect(l.RectTransform, x, yBottom, w, h); return l; } private static CuiButton NewButton(string cmd, string bg, string text, int size, string tcolor, bool bold, int x, int yBottom, int w, int h) { var b = new CuiButton { Button = { Command = cmd, Color = bg }, Text = { Text = text, FontSize = size, Font = bold ? FONT_B : FONT_R, Align = TextAnchor.MiddleCenter, Color = tcolor } }; SetRect(b.RectTransform, x, yBottom, w, h); return b; } private static CuiRectTransformComponent RawRect(int x, int yBottom, int w, int h) { return new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = $"{x} {yBottom}", OffsetMax = $"{x + w} {yBottom + h}" }; } private static CuiElement NewImage(string parent, string png, int x, int yBottom, int w, int h) { // 🔴 必须用 CuiRawImageComponent:FileStorage 的数字 id 只有 RawImage 认得, // 普通 CuiImageComponent(UnityEngine.UI.Image)把它当 sprite 名,找不到就渲染成白色剪影 return new CuiElement { Parent = parent, Components = { new CuiRawImageComponent { Png = png, Color = "1 1 1 1" }, RawRect(x, yBottom, w, h) } }; } #endregion #region // ===== 配置 ===== private ConfigData _cfg; private class ConfigData { [JsonProperty("通用")] public GeneralCfg General = new GeneralCfg(); [JsonProperty("强化")] public UpgradeCfg Upgrade = new UpgradeCfg(); [JsonProperty("宝石")] public GemCfg Gem = new GemCfg(); [JsonProperty("祝福值")] public BlessCfg Bless = new BlessCfg(); [JsonProperty("公告")] public AnnounceCfg Announce = new AnnounceCfg(); [JsonProperty("自绘HUD")] public HudCfg Hud = new HudCfg(); [JsonProperty("采集掉落")] public DropCfg Drop = new DropCfg(); [JsonProperty("洗点(重置药剂)")] public ResetCfg Reset = new ResetCfg(); [JsonProperty("授权 Authorize")] public AuthData Authorize = new AuthData(); } // 插件授权配置。授权码留空 = 未授权(面板只显示激活说明页,属性上限不施加)。 private class AuthData { [JsonProperty("插件授权码 LicenseCode")] public string LicenseCode = ""; [JsonProperty("离线宽限天数 OfflineGraceDays")] public int OfflineGraceDays = 3; // 只在「连不上授权服务器」时才启用,服务端明确拒绝则一秒都不给 [JsonProperty("启动时检查新版本 CheckUpdate")] public bool CheckUpdate = true; } private class GeneralCfg { [JsonProperty("屏幕悬浮小按钮")] public bool HoverButton = true; } private class UpgradeCfg { [JsonProperty("等级上限")] public int MaxLevel = 20; [JsonProperty("新手保护:目标等级不超过此值时永不失败")] public int SafeLevel = 5; [JsonProperty("基础生命上限")] public float BaseHp = 100f; [JsonProperty("基础水分上限")] public float BaseWater = 250f; [JsonProperty("基础饱食上限")] public float BaseFood = 500f; [JsonProperty("每级加成:生命")] public float PerHp = 10f; [JsonProperty("每级加成:水分")] public float PerWater = 15f; [JsonProperty("每级加成:饱食")] public float PerFood = 25f; } private class GemTierCfg { [JsonProperty("名称")] public string Name = "碎裂"; [JsonProperty("皮肤ID")] public ulong Skin; [JsonProperty("成功率%")] public float Rate = 60f; } private class GemCfg { [JsonProperty("载体物品短名")] public string Carrier = "bone.fragments"; [JsonProperty("按钮模式:单次消耗数量")] public int Cost = 3; [JsonProperty("品质列表")] public List Tiers = new List { new GemTierCfg { Name = "碎裂", Skin = 2917836458UL, Rate = 60f }, new GemTierCfg { Name = "普通", Skin = 2917835395UL, Rate = 75f }, new GemTierCfg { Name = "精制", Skin = 2917837827UL, Rate = 88f }, new GemTierCfg { Name = "完美", Skin = 2917837165UL, Rate = 100f }, }; } private class BlessCfg { [JsonProperty("每次失败增加(%)")] public float Gain = 8f; [JsonProperty("祝福值上限(%)")] public float Cap = 80f; } private class AnnounceCfg { [JsonProperty("全服公告起始等级")] public int Start = 11; [JsonProperty("红色公告起始等级")] public int Red = 16; } private class HudCfg { [JsonProperty("启用自绘HUD(显示当前值/上限)")] public bool Enabled = true; [JsonProperty("刷新间隔秒")] public float Interval = 2f; } private class DropCfg { [JsonProperty("启用采集掉落")] public bool Enabled = true; [JsonProperty("石头矿:碎裂概率%")] public float Stone = 4f; [JsonProperty("金属矿:碎裂概率%")] public float Metal = 3f; [JsonProperty("硫磺矿:普通概率%")] public float Sulfur = 3f; [JsonProperty("砍树:碎裂概率%")] public float Tree = 2f; [JsonProperty("捡采集物:普通概率%")] public float Pick = 5f; [JsonProperty("每次掉落数量")] public int Amount = 1; [JsonProperty("击杀科学家:普通概率%")] public float Npc = 6f; [JsonProperty("击杀动物:碎裂概率%")] public float Animal = 4f; } private class ResetCfg { [JsonProperty("洗点每级返还碎裂宝石数")] public int RefundPerLevel = 1; } protected override void LoadDefaultConfig() { _cfg = new ConfigData(); SaveConfig(); Puts("配置文件为空,已写入默认配置"); } protected override void LoadConfig() { base.LoadConfig(); try { _cfg = Config.ReadObject(); if (_cfg == null) throw new Exception("null"); if (_cfg.Gem == null || _cfg.Gem.Tiers == null || _cfg.Gem.Tiers.Count == 0) throw new Exception("gem"); } catch { Puts("配置文件损坏,已重置为默认"); LoadDefaultConfig(); } SaveConfig(); } protected override void SaveConfig() => Config.WriteObject(_cfg, true); #endregion #region // ===== 数据 ===== private class PlayerData { public int Hp; public int Water; public int Food; public float BlessHp; public float BlessWater; public float BlessFood; } private Dictionary _data = new Dictionary(); // 会话状态 private class Session { public int SelTier; // 按钮模式投入品质 } private Dictionary _sess = new Dictionary(); private Session Sess(ulong id) { Session s; if (!_sess.TryGetValue(id, out s)) { s = new Session(); _sess[id] = s; } return s; } private PlayerData GetData(ulong id) { PlayerData pd; if (!_data.TryGetValue(id, out pd)) { pd = new PlayerData(); _data[id] = pd; } return pd; } private void LoadData() { try { _data = Interface.Oxide.DataFileSystem.ReadObject>("TZqianghua/PlayerData") ?? new Dictionary(); } catch { _data = new Dictionary(); } Puts($"已加载 {_data.Count} 名玩家的体质数据"); } private void SaveData() => Interface.Oxide.DataFileSystem.WriteObject("TZqianghua/PlayerData", _data); #endregion #region // ===== 生命周期 ===== private void Init() { _unloaded = false; LoadData(); } private void OnServerInitialized() { InitAuthorize(); // 🔴 授权判定必须在前面(未授权不施加属性上限) timer.Once(8f, RegisterIcons); timer.Every(300f, SaveData); if (_authorized) CheckPluginVersion(); foreach (var p in BasePlayer.activePlayerList) { if (p == null || !p.IsConnected) continue; if (!_authorized) { ApplyBase(p); continue; } // 未授权 → 恢复原版上限 Apply(p); if (_cfg.General.HoverButton) timer.Once(10f, () => { if (p != null && p.IsConnected) ShowHover(p); }); if (_cfg.Hud.Enabled) StartHud(p); } if (_cfg.Hud.Enabled) timer.Every(_cfg.Hud.Interval, HudTick); Puts($"体质强化 v{Version} 就绪:上限 生命{_cfg.Upgrade.BaseHp + _cfg.Upgrade.PerHp * _cfg.Upgrade.MaxLevel} / " + $"水分{_cfg.Upgrade.BaseWater + _cfg.Upgrade.PerWater * _cfg.Upgrade.MaxLevel} / " + $"饱食{_cfg.Upgrade.BaseFood + _cfg.Upgrade.PerFood * _cfg.Upgrade.MaxLevel}"); } private void Unload() { _unloaded = true; SaveData(); foreach (var p in BasePlayer.activePlayerList) { CuiHelper.DestroyUi(p, UI_MAIN); CuiHelper.DestroyUi(p, UI_HOVER); CuiHelper.DestroyUi(p, UI_HUD); } } private void RegisterIcons() { if (ImgLib() == null) { PrintWarning("ImageLibrary 未加载,面板将没有图标"); return; } string dir = Interface.Oxide.DataDirectory + "/TZqianghua/icons"; var map = new Dictionary { { "TZQ_GEM_0", "gem-0.png" }, { "TZQ_GEM_1", "gem-1.png" }, { "TZQ_GEM_2", "gem-2.png" }, { "TZQ_GEM_3", "gem-3.png" }, { "TZQ_ICON_HP", "icon-hp.png" }, { "TZQ_ICON_WATER", "icon-water.png" }, { "TZQ_ICON_FOOD", "icon-food.png" }, }; // 🔴 ImageLibrary 的 AddImage(url,...) 走 UnityWebRequest 下载,只吃 http(s) 网址, // 传本地路径必然 "Cannot connect to destination host"。 // 本地 PNG 必须用 AddImageData(name, bytes, id) → 直接写进游戏 FileStorage, // 之后 GetImage(key) 返回的是数字文件 id(不是 URL),CUI 里当 sprite 用即可。 var arr = new Dictionary(); foreach (var kv in map) { string path = dir + "/" + kv.Value; if (!System.IO.File.Exists(path)) { PrintWarning("缺少图标文件: " + path); continue; } try { byte[] b = System.IO.File.ReadAllBytes(path); ImageLibrary.Call("AddImageData", kv.Key, b, 0UL); arr[kv.Key] = b; _iconReady.Add(kv.Key); } catch (Exception ex) { PrintWarning("读图标失败 " + kv.Value + " : " + ex.Message); } } _iconNone = (ImageLibrary.Call("GetImage", "NONE") as string) ?? ""; timer.Once(3f, () => { int ok = 0; foreach (var k in _iconReady) { string id = ImageLibrary.Call("GetImage", k) as string; if (!string.IsNullOrEmpty(id) && id != _iconNone && id != "0") ok++; } Puts($"图标注册完成:{ok}/{map.Count} 已入库" + (ok < map.Count ? "(不足则面板会用色块兜底)" : "")); }); } private Plugin ImgLib() { if (ImageLibrary == null) ImageLibrary = plugins.Find("ImageLibrary"); return ImageLibrary; } /// 返回可用于 CUI sprite 的文件 id;不可用时返回 "0",调用方回退到色块 private string Img(string name) { if (ImgLib() == null || !_iconReady.Contains(name)) return "0"; var r = ImageLibrary.Call("GetImage", name) as string; if (string.IsNullOrEmpty(r) || r == "0" || r == _iconNone) return "0"; return r; } #endregion #region // ===== 属性生效 ===== private void Apply(BasePlayer player) { if (player == null || !player.IsConnected) return; var pd = GetData(player.userID); player._maxHealth = _cfg.Upgrade.BaseHp + pd.Hp * _cfg.Upgrade.PerHp; player.metabolism.hydration.max = _cfg.Upgrade.BaseWater + pd.Water * _cfg.Upgrade.PerWater; player.metabolism.calories.max = _cfg.Upgrade.BaseFood + pd.Food * _cfg.Upgrade.PerFood; // 🔴 降级/洗点后当前值必须收缩到新上限,否则玩家可"升满→喝水囤满→洗点"白嫖超额水分/饱食 if (player.health > player._maxHealth) player.health = player._maxHealth; if (player.metabolism.hydration.value > player.metabolism.hydration.max) player.metabolism.hydration.value = player.metabolism.hydration.max; if (player.metabolism.calories.value > player.metabolism.calories.max) player.metabolism.calories.value = player.metabolism.calories.max; player.SendNetworkUpdate(); // 水/饿的 value/max 由 ServerTick 自动网络同步(实测结论,没有手动同步方法) } private void OnPlayerConnected(BasePlayer player) { if (player == null) return; timer.Once(3f, () => { if (player == null || !player.IsConnected) return; if (!_authorized) { ApplyBase(player); return; } Apply(player); if (_cfg.General.HoverButton) ShowHover(player); if (_cfg.Hud.Enabled) StartHud(player); }); } private void OnPlayerRespawned(BasePlayer player) { if (player == null) return; timer.Once(1f, () => { if (player == null || !player.IsConnected) return; if (_authorized) Apply(player); else ApplyBase(player); }); } private void OnPlayerDisconnected(BasePlayer player, string reason) { if (player == null) return; Session s; if (_sess.TryGetValue(player.userID, out s)) _sess.Remove(player.userID); } #endregion #region // ===== 授权(插件授权码 · 联网校验 · 离线宽限)===== // ===================================================================== // 【软锁】策略(全项目统一标准,与 LoginRewards v1.1.3 一致): // · 未授权时插件【照常加载、不报错、不卸载】,命令照常注册 —— 服主能直接用 // tzqianghua.auth <码> 自救,不会卡在"卸载了就没法激活"的死循环。 // · 只在「功能入口」拦一道(EnsureAuthorized):未授权时管理员弹激活说明页, // 普通玩家静默拒绝(不刷屏、不报红字,玩家看不出服务器有问题)。 // · 联网校验走 webrequest 异步回调,绝不阻塞服务器主线程。 // · 离线宽限 3 天,且【只在「连不上服务器」时生效】;服务端一旦【明确拒绝】 // (码不存在 / 已停用 / 不是本插件的码 / 台数已满)→ 当场锁,一秒宽限都不给。 // · 常驻复核每 6 小时一次 —— 否则后台停用/删码后靠本地宽限能一直跑到过期。 // ===================================================================== private const string AuthApiBase = "http://lic.biubr.com"; // 授权服务地址,换服只改这一处 private const string AuthPluginId = "tzqianghua"; // 必须与后台插件清单的 plugin_id 一致 private const string AuthCacheFile = "TZqianghua.auth"; // -> oxide/data/TZqianghua.auth.json private const float AuthRetrySeconds = 300f; // 未授权时每 5 分钟自动重试 private const float AuthRenewHours = 6f; // 常驻复核间隔(后台撤销最长 6h 生效) private const float AuthNudgeSeconds = 600f; // 未授权时往控制台写提醒的间隔 private const int AuthMaxGraceDays = 3; // 离线宽限硬上限(配置改大也不认) private bool _unloaded; // 已卸载,异步回调直接丢弃 private bool _authorized; // 当前是否已授权(false 时属性上限不施加、面板只显示激活页) private bool _authPending; // 正在联网校验中,避免重复请求 private AuthCache _authCache; // 本地授权缓存 private string _authHint = "尚未校验"; private string _latestVersion = ""; private int _nudgeCount; 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 = ""; } 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; } private int GraceDays { get { int d = (_cfg != null && _cfg.Authorize != null) ? _cfg.Authorize.OfflineGraceDays : 3; return Mathf.Clamp(d, 0, AuthMaxGraceDays); // 🔴 硬上限 3 天,买家改成 365 也不认 } } 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 = (_cfg != null && _cfg.Authorize != null && _cfg.Authorize.LicenseCode != null) ? _cfg.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 以上 = 有人在拨表 → 强制联网复核 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 = (_cfg != null && _cfg.Authorize != null && _cfg.Authorize.LicenseCode != null) ? _cfg.Authorize.LicenseCode.Trim() : ""; if (c.Length > 0) return c.ToUpperInvariant(); return (_authCache != null && _authCache.Code != null) ? _authCache.Code.Trim().ToUpperInvariant() : ""; } private void InitAuthorize() { LoadAuthCache(); timer.Every(AuthNudgeSeconds, AuthNudge); timer.Every(AuthRenewHours * 3600f, AuthRenew); if (CacheFresh()) { _authorized = true; _authHint = "已授权(本地凭据有效)"; Puts("[授权] 已授权,本地凭据有效期至 " + GraceExpireText() + "(其间断网也能正常使用)。"); DateTime last; if (!TryParseUtc(_authCache.LastOk, out last) || (DateTime.UtcNow - last).TotalHours >= AuthRenewHours) RequestVerify(null, null, false); return; } _authorized = false; if (string.IsNullOrEmpty(CurrentCode())) { _authHint = "未填写授权码"; PrintWarning("本插件尚未授权:属性上限不会施加,面板只显示激活说明页。"); PrintWarning("激活方法:配置文件「授权 Authorize → 插件授权码 LicenseCode」填入 PL- 开头的码,"); PrintWarning("或在服务器控制台执行:tzqianghua.auth <你的授权码>,然后 oxide.reload TZqianghua。"); PrintAuthStatus(); return; } _authHint = "正在联网校验…"; RequestVerify(null, null, false); } private void RetryAuth() { if (_unloaded || _authorized) return; if (string.IsNullOrEmpty(CurrentCode())) return; RequestVerify(null, null, false); } // 常驻复核:已授权状态也必须定期问一次,否则后台停用后靠本地宽限能一直跑到过期 private void AuthRenew() { if (_unloaded) return; if (string.IsNullOrEmpty(CurrentCode())) return; RequestVerify(null, null, false); } private void AuthNudge() { if (_unloaded || _authorized) return; if (_nudgeCount++ >= 3) return; Puts("★ 本插件尚未授权,功能不可用(" + _authHint + ")。" + "激活:控制台执行 tzqianghua.auth PL-XXXX-XXXX-XXXX 后重载插件"); } // code 传空 = 用现有码续期;manual 非空 = 服主刚手动填码,要回显结果 private void RequestVerify(string code, BasePlayer manual, bool manualInput) { if (_unloaded) return; if (_authPending) { if (manual != null) manual.ChatMessage("正在校验中,请稍候…"); return; } string use = string.IsNullOrEmpty(code) ? CurrentCode() : code.Trim().ToUpperInvariant(); if (string.IsNullOrEmpty(use)) { if (manual != null) manual.ChatMessage("还没有授权码。用法:tzqianghua.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; } // ---- 校验通过 ---- bool wasAuthorized = _authorized; _authorized = true; _nudgeCount = 0; _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) { PrintWarning("[版本] 后台登记最新版为 v" + _latestVersion + ",你当前是 v" + Version.ToString() + "。功能不受影响,方便时更新即可。"); } _authHint = "已授权"; Puts("[授权] 校验通过,插件已生效。授权码 " + MaskCode(code) + ",本地凭据有效期至 " + GraceExpireText() + "。"); PrintAuthStatus(); if (manualInput && manual != null) manual.ChatMessage("授权成功,插件已生效。"); // 从「未授权」翻成「已授权」→ 立刻给在线玩家补上属性上限与 HUD if (!wasAuthorized) { foreach (var p in BasePlayer.activePlayerList) { if (p == null || !p.IsConnected) continue; Apply(p); if (_cfg.Hud.Enabled) StartHud(p); } } } // serverRejected=true = 服务端明确拒绝,不接受离线宽限 private void OnAuthFailed(BasePlayer manual, string reason, bool serverRejected, bool manualInput, string attempted) { if (_unloaded) return; // 情形一:这次用的码没通过,但本地还存着另一份有效凭据 → 自动改回有效码重校验 // (服主手抖打错一个字符,不会把原本正常的服务器搞停) if (serverRejected && CacheFresh() && !string.IsNullOrEmpty(_authCache.Code) && !string.Equals(_authCache.Code, attempted, StringComparison.OrdinalIgnoreCase)) { string good = _authCache.Code; _cfg.Authorize.LicenseCode = good; SaveConfig(); PrintWarning("[授权] 这次用的码没通过(" + reason + "),已自动改回原来有效的授权码 " + MaskCode(good) + " 并重新校验。"); if (manual != null) manual.ChatMessage("这个码没通过(" + reason + "),已改回原来的授权码并重新校验…"); RequestVerify(good, null, false); return; } // 情形二:网络类问题 + 还在宽限期 → 继续放行 if (!serverRejected && CacheFresh()) { _authorized = true; _authHint = "已授权(离线宽限中)"; PrintWarning("[授权] 本次联网校验没成功(" + reason + "),但本地凭据仍在 " + GraceDays + " 天宽限期内,插件继续正常工作。"); if (manual != null) manual.ChatMessage("联网校验没成功(" + reason + "),但本地凭据仍在宽限期内,功能不受影响。"); timer.Once(AuthRetrySeconds, RetryAuth); return; } // 情形三:真没授权 → 功能不生效 // 服务端明确拒绝时抹掉 last_ok(Code 保留)→ 宽限立刻失效,码恢复后能在 5 分钟重试里自动复活 bool wasAuthorized = _authorized; 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,且买的是本插件;重新校验:控制台执行 tzqianghua.auth <码>。"); if (manual != null) manual.ChatMessage("授权失败:" + reason); // 授权掉了就把界面收掉并把属性恢复原版(不能留一个还能吃加成的空壳) if (wasAuthorized) { foreach (var p in BasePlayer.activePlayerList) { if (p == null || !p.IsConnected) continue; CuiHelper.DestroyUi(p, UI_MAIN); CuiHelper.DestroyUi(p, UI_HUD); CuiHelper.DestroyUi(p, UI_HOVER); ApplyBase(p); } } timer.Once(AuthRetrySeconds, RetryAuth); } private void PrintAuthStatus() { Puts("===== 体质强化 TZqianghua 授权状态 ====="); Puts(" 状态:" + (_authorized ? "已授权(功能正常)" : "未授权(属性上限不施加)")); Puts(" 授权码:" + MaskCode(CurrentCode())); Puts(" 插件版本:" + Version.ToString() + (string.IsNullOrEmpty(_latestVersion) ? "" : ("(后台最新 " + _latestVersion + ")"))); Puts(" 本地凭据:" + (CacheFresh() ? ("有效,至 " + GraceExpireText()) : "无 / 已过期")); Puts(" 主动复核:每 " + AuthRenewHours + " 小时一次;仅「连不上服务器」时才用 " + GraceDays + " 天宽限"); Puts(" 用法:tzqianghua.auth (不带参数 = 只看状态)"); Puts(" tzqianghua.deauth(注销:清本地凭据 + 清配置里的码,立刻回到未授权)"); } 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 || webrequest == null) return; if (_cfg == null || _cfg.Authorize == null || !_cfg.Authorize.CheckUpdate) 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) PrintWarning("[版本] 后台登记最新版为 v" + _latestVersion + ",你当前是 v" + Version.ToString() + "。"); } catch (Exception) { /* 查不到就静默,绝不拦 */ } }, this, RequestMethod.GET, null, 15f); } /// 软锁统一入口:返回 true 才继续执行功能;未授权时管理员看激活页,普通玩家静默拒绝 private bool EnsureAuthorized(BasePlayer player) { if (_authorized) return true; if (player != null && player.IsAdmin) ShowAuthPanel(player); return false; } /// /// 把玩家上限恢复成原版(未授权 / 授权被撤销时用)。 /// ⚠️ 故意【不动水/饿的当前值】:授权出问题不该顺手毁掉玩家的水量存量, /// 水/饿超过新上限时无法继续补充、自然消耗掉即可;只有血量必须收敛(血不能超上限)。 /// private void ApplyBase(BasePlayer player) { if (player == null || !player.IsConnected) return; player._maxHealth = 100f; player.metabolism.hydration.max = 250f; player.metabolism.calories.max = 500f; if (player.health > player._maxHealth) player.health = player._maxHealth; player.SendNetworkUpdate(); } /// 未授权时给管理员看的激活说明页 private void ShowAuthPanel(BasePlayer player) { CuiHelper.DestroyUi(player, UI_MAIN); var c = new CuiElementContainer(); var root = c.Add(new CuiPanel { Image = { Color = C_BG }, RectTransform = { AnchorMin = "0.5 0.5", AnchorMax = "0.5 0.5", OffsetMin = "-280 -150", OffsetMax = "280 150" }, CursorEnabled = true }, "Overlay", UI_MAIN); c.Add(NewPanel(C_TITLE, 0, 248, 560, 52), root); c.Add(NewLabel("⚡ 体质强化 · 未授权", 16, C_WHITE, TextAnchor.MiddleLeft, true, 22, 248, 400, 52), root); c.Add(NewButton(CMD + " closeui", C_RED, "✕", 14, C_WHITE, true, 510, 262, 32, 32), root); c.Add(NewLabel("本插件尚未授权,属性上限不会生效。", 13, C_GOLD, TextAnchor.MiddleLeft, true, 24, 196, 512, 24), root); c.Add(NewLabel("当前状态:" + _authHint, 11, C_GRAY, TextAnchor.MiddleLeft, false, 24, 170, 512, 20), root); c.Add(NewLabel("第 1 步 配置文件「授权 Authorize → 插件授权码 LicenseCode」填入 PL- 开头的码", 11, C_WHITE, TextAnchor.MiddleLeft, false, 24, 132, 520, 20), root); c.Add(NewLabel("第 2 步 或服务器控制台 / RCON / F1 执行: tzqianghua.auth PL-XXXX-XXXX-XXXX", 11, C_WHITE, TextAnchor.MiddleLeft, false, 24, 108, 520, 20), root); c.Add(NewLabel("第 3 步 执行后重新 oxide.reload TZqianghua(填码后会自动联网校验)", 11, C_WHITE, TextAnchor.MiddleLeft, false, 24, 84, 520, 20), root); c.Add(NewLabel("授权码已填:" + MaskCode(CurrentCode()) + " | 服务器出网需放行 101.33.68.238:20771", 10, C_GRAY, TextAnchor.MiddleLeft, false, 24, 44, 520, 20), root); c.Add(NewLabel("断网不影响:只在「连不上授权服务器」时才启用 " + GraceDays + " 天离线宽限", 10, C_GRAY, TextAnchor.MiddleLeft, false, 24, 22, 520, 20), root); CuiHelper.AddUi(player, c); } #endregion #region // ===== 自绘 HUD(解决原版不显示上限问题)===== private void StartHud(BasePlayer player) => HudTickFor(player); private void HudTick() { foreach (var p in BasePlayer.activePlayerList) HudTickFor(p); } private void HudTickFor(BasePlayer player) { if (player == null || !player.IsConnected) return; if (!_authorized) return; // 🔴 未授权不显示 HUD var pd = GetData(player.userID); CuiHelper.DestroyUi(player, UI_HUD); var c = new CuiElementContainer(); // 🔴 直接盖在原生血/水/食条上(右下角,实测:右 22~218、底 22~96)。 // 原版条的血按 100、水/饿按 250/500 显示比例,和插件上限对不上会"参差不齐", // 所以整块盖掉,统一用插件上限显示 当前值/上限。 var root = c.Add(new CuiPanel { Image = { Color = "0.086 0.094 0.114 0.97" }, RectTransform = { AnchorMin = "1 0", AnchorMax = "1 0", OffsetMin = "-225 12", OffsetMax = "-12 100" }, CursorEnabled = false }, "Overlay", UI_HUD); float[] vals = { player.health, player.metabolism.hydration.value, player.metabolism.calories.value }; float[] caps = { _cfg.Upgrade.BaseHp + pd.Hp * _cfg.Upgrade.PerHp, _cfg.Upgrade.BaseWater + pd.Water * _cfg.Upgrade.PerWater, _cfg.Upgrade.BaseFood + pd.Food * _cfg.Upgrade.PerFood }; string[] names = { "生命", "水分", "饱食" }; string[] cols = { C_HP, C_WATER, C_FOOD }; int[] lv = { pd.Hp, pd.Water, pd.Food }; for (int i = 0; i < 3; i++) { int y = 58 - i * 28; string ic = Img(i == 0 ? "TZQ_ICON_HP" : i == 1 ? "TZQ_ICON_WATER" : "TZQ_ICON_FOOD"); if (ic != "0") c.Add(NewImage(root, ic, 4, y + 3, 20, 20)); c.Add(NewPanel(C_TITLE, 28, y + 3, 130, 20), root); float frac = Mathf.Clamp01(vals[i] / Mathf.Max(1f, caps[i])); c.Add(NewPanel(cols[i], 29, y + 4, (int)(128 * frac), 18), root); c.Add(NewLabel($"{(int)vals[i]}/{(int)caps[i]}", 10, C_WHITE, TextAnchor.MiddleCenter, true, 28, y + 3, 130, 20), root); c.Add(NewLabel($"{names[i]}{lv[i]}级", 10, C_WHITE, TextAnchor.MiddleCenter, true, 160, y + 3, 50, 20), root); } CuiHelper.AddUi(player, c); } // 受伤/回血后立即刷新 HUD(不等 2 秒轮询) // HUD 刷新节流:受伤高频 hit(散弹/爆炸一次十几个)不逐个排定时器,0.5s 内只排一次 private readonly Dictionary _lastHudReq = new Dictionary(); private void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) { if (!_cfg.Hud.Enabled) return; var p = entity as BasePlayer; if (p == null || !p.IsConnected) return; float now = UnityEngine.Time.time; float last; if (_lastHudReq.TryGetValue(p.userID, out last) && now - last < 0.5f) return; _lastHudReq[p.userID] = now; timer.Once(0.15f, () => { if (p != null && p.IsConnected) HudTickFor(p); }); } #endregion #region // ===== 宝石工具 ===== private static IEnumerable AllItems(BasePlayer player) { var inv = player.inventory; if (inv?.containerMain != null) foreach (var it in inv.containerMain.itemList) yield return it; if (inv?.containerBelt != null) foreach (var it in inv.containerBelt.itemList) yield return it; } private int CountGems(BasePlayer player, ulong skin) { int n = 0; foreach (var it in AllItems(player)) if (it.info.shortname == _cfg.Gem.Carrier && it.skin == skin) n += (int)it.amount; return n; } private bool TakeGems(BasePlayer player, ulong skin, int amount) { if (CountGems(player, skin) < amount) return false; int left = amount; foreach (var it in AllItems(player)) { if (left <= 0) break; if (it.info.shortname != _cfg.Gem.Carrier || it.skin != skin) continue; int take = Mathf.Min(left, (int)it.amount); it.UseItem(take); left -= take; } return true; } private void GiveGem(BasePlayer player, int tier, int amount) { var item = ItemManager.CreateByName(_cfg.Gem.Carrier, amount, _cfg.Gem.Tiers[tier].Skin); if (item != null) player.GiveItem(item, BasePlayer.GiveItemReason.PickedUp); } private int GemTierOf(Item item) { if (item.info.shortname != _cfg.Gem.Carrier) return -1; for (int i = 0; i < _cfg.Gem.Tiers.Count; i++) if (_cfg.Gem.Tiers[i].Skin == item.skin) return i; return -1; } // 祝福石(紫星)与洗点药剂(红药)皮肤 private readonly ulong _reservedBless = 2917838308UL; private readonly ulong _reservedReset = 2917840703UL; #endregion #region // ===== 强化核心 ===== private int GetLevel(PlayerData pd, int line) => line == 0 ? pd.Hp : line == 1 ? pd.Water : pd.Food; private void SetLevel(PlayerData pd, int line, int v) { if (line == 0) pd.Hp = v; else if (line == 1) pd.Water = v; else pd.Food = v; } private float GetBless(PlayerData pd, int line) => line == 0 ? pd.BlessHp : line == 1 ? pd.BlessWater : pd.BlessFood; private void SetBless(PlayerData pd, int line, float v) { if (line == 0) pd.BlessHp = v; else if (line == 1) pd.BlessWater = v; else pd.BlessFood = v; } private string LineName(int line) => line == 0 ? "生命" : line == 1 ? "水分" : "饱食"; private float LineBase(int line) => line == 0 ? _cfg.Upgrade.BaseHp : line == 1 ? _cfg.Upgrade.BaseWater : _cfg.Upgrade.BaseFood; private float LinePer(int line) => line == 0 ? _cfg.Upgrade.PerHp : line == 1 ? _cfg.Upgrade.PerWater : _cfg.Upgrade.PerFood; private int LineCap(PlayerData pd, int line) => (int)(LineBase(line) + GetLevel(pd, line) * LinePer(line)); // 核心判定:tiers 为本次投入的宝石品质列表(长度=消耗数) private void DoUpgrade(BasePlayer player, int line, List tiers) { var pd = GetData(player.userID); int lv = GetLevel(pd, line); if (lv >= _cfg.Upgrade.MaxLevel) { player.ChatMessage($"{LineName(line)} 已达到最高等级 Lv.{_cfg.Upgrade.MaxLevel},宝石已退回"); return; } // 成功率 = 投入宝石平均 + 祝福值;新手保护必成 float baseRate = 0f; foreach (var t in tiers) baseRate += _cfg.Gem.Tiers[Mathf.Clamp(t, 0, _cfg.Gem.Tiers.Count - 1)].Rate; baseRate /= tiers.Count; int target = lv + 1; float rate = target <= _cfg.Upgrade.SafeLevel ? 100f : Mathf.Clamp(baseRate + GetBless(pd, line), 0f, 100f); bool ok = UnityEngine.Random.Range(0f, 100f) < rate; if (ok) { SetLevel(pd, line, target); SetBless(pd, line, 0f); Effect.server.Run(SFX_OK, player.transform.position); player.ChatMessage($"强化成功!{LineName(line)} Lv.{lv} → Lv.{target}(上限 {LineCap(pd, line)})"); if (target >= _cfg.Announce.Start) { string msg = target >= _cfg.Announce.Red ? $"【体质强化】{player.displayName} 的{LineName(line)}已强化至 Lv.{target}!" : $"【体质强化】{player.displayName} 的{LineName(line)}已强化至 Lv.{target}"; Server.Broadcast(msg); } } else { float b = Mathf.Min(GetBless(pd, line) + _cfg.Bless.Gain, _cfg.Bless.Cap); SetBless(pd, line, b); Effect.server.Run(SFX_FAIL, player.transform.position); player.ChatMessage($"强化失败:{LineName(line)}保持 Lv.{lv},下次成功率 +{_cfg.Bless.Gain:F0}%(当前 {b:F0}%)"); } SaveData(); Apply(player); HudTickFor(player); } // 按钮模式:从背包扣指定品质的 N 颗 private void TryUpgradeFromBag(BasePlayer player, int line) { if (!EnsureAuthorized(player)) return; var pd = GetData(player.userID); if (GetLevel(pd, line) >= _cfg.Upgrade.MaxLevel) { player.ChatMessage("已达最高等级"); return; } int tier = Mathf.Clamp(Sess(player.userID).SelTier, 0, _cfg.Gem.Tiers.Count - 1); int need = Mathf.Max(1, _cfg.Gem.Cost); // 🔴 防配置成 0 → 除零/空列表 if (!TakeGems(player, _cfg.Gem.Tiers[tier].Skin, need)) { player.ChatMessage($"{_cfg.Gem.Tiers[tier].Name}宝石不足:需要 ×{need},当前 ×{CountGems(player, _cfg.Gem.Tiers[tier].Skin)}"); return; } var tiers = new List(); for (int i = 0; i < need; i++) tiers.Add(tier); DoUpgrade(player, line, tiers); } #endregion #region // ===== GUI 操作(祝福石 / 洗点,全部只动背包)===== /// 使用祝福石:从背包扣 1 颗紫星,给指定线加祝福值(失败加成的同款机制) private void UseBlessStone(BasePlayer player, int line) { if (!EnsureAuthorized(player)) return; var pd = GetData(player.userID); float cur = GetBless(pd, line); if (cur >= _cfg.Bless.Cap) { player.ChatMessage($"祝福值已满({_cfg.Bless.Cap:F0}%),先强化把它消耗掉"); return; } if (!TakeGems(player, _reservedBless, 1)) { player.ChatMessage("背包里没有祝福石(紫色星星皮肤)"); return; } float b = Mathf.Min(cur + _cfg.Bless.Gain, _cfg.Bless.Cap); SetBless(pd, line, b); SaveData(); Effect.server.Run(SFX_OK, player.transform.position); player.ChatMessage($"祝福石生效:{LineName(line)} 祝福 +{_cfg.Bless.Gain:F0}% → {b:F0}%"); } /// 洗点核心:消耗背包 1 瓶洗点药剂,指定线归零并返还碎裂宝石 private void ResetLineCore(BasePlayer player, int line) { if (!EnsureAuthorized(player)) return; var pd = GetData(player.userID); int lv = GetLevel(pd, line); if (lv <= 0) { player.ChatMessage("这条线本来就是 0 级,不用洗点"); return; } if (!TakeGems(player, _reservedReset, 1)) { player.ChatMessage("背包里没有洗点药剂(红色药水皮肤)"); return; } SetLevel(pd, line, 0); SetBless(pd, line, 0f); int refund = lv * _cfg.Reset.RefundPerLevel; GiveGem(player, 0, refund); SaveData(); Apply(player); HudTickFor(player); Effect.server.Run(SFX_OK, player.transform.position); player.ChatMessage($"洗点完成:{LineName(line)} Lv.{lv} → 0,返还碎裂宝石 ×{refund}"); } #endregion #region // ===== 按钮模式面板(/tzui)===== private void ShowMain(BasePlayer player) { if (!EnsureAuthorized(player)) return; // 🔴 软锁:未授权只给管理员看激活页 CuiHelper.DestroyUi(player, UI_MAIN); var s = Sess(player.userID); var pd = GetData(player.userID); int sel = Mathf.Clamp(s.SelTier, 0, _cfg.Gem.Tiers.Count - 1); var c = new CuiElementContainer(); var root = c.Add(new CuiPanel { Image = { Color = C_BG }, RectTransform = { AnchorMin = "1 0.5", AnchorMax = "1 0.5", OffsetMin = "-532 -300", OffsetMax = "-20 300" }, CursorEnabled = true }, "Overlay", UI_MAIN); c.Add(NewPanel(C_TITLE, 0, 548, 512, 52), root); c.Add(NewLabel("⚡ 体质强化", 16, C_WHITE, TextAnchor.MiddleLeft, true, 22, 548, 220, 52), root); c.Add(NewLabel("TZqianghua v" + Version, 10, C_GRAY, TextAnchor.MiddleLeft, false, 180, 548, 200, 52), root); c.Add(NewButton(CMD + " closeui", C_RED, "✕", 14, C_WHITE, true, 462, 562, 32, 32), root); var gp = c.Add(NewPanel(C_ROW, 20, 470, 472, 64), root); for (int i = 0; i < _cfg.Gem.Tiers.Count && i < 4; i++) { int gx = 16 + i * 80; var slot = c.Add(NewPanel(C_SLOT, gx, 8, 48, 48), gp); string png = Img("TZQ_GEM_" + i); if (png != "0") c.Add(NewImage(slot, png, 4, 4, 40, 40)); else c.Add(NewPanel(TierColor[i], gx + 8, 16, 32, 32), gp); c.Add(NewLabel(CountGems(player, _cfg.Gem.Tiers[i].Skin).ToString(), 10, C_WHITE, TextAnchor.MiddleRight, true, gx + 18, 44, 28, 14), gp); c.Add(NewLabel(_cfg.Gem.Tiers[i].Name, 9, C_GRAY, TextAnchor.MiddleCenter, false, gx, 8, 48, 14), gp); } c.Add(NewLabel("背包宝石自动识别", 10, C_GRAY, TextAnchor.MiddleCenter, false, 340, 34, 120, 20), gp); c.Add(NewLabel("点强化自动扣取", 10, C_GRAY, TextAnchor.MiddleCenter, false, 340, 12, 120, 20), gp); string[] icons = { "TZQ_ICON_HP", "TZQ_ICON_WATER", "TZQ_ICON_FOOD" }; string[] lineColors = { C_HP, C_WATER, C_FOOD }; string[] keys = { "hp", "water", "food" }; for (int li = 0; li < 3; li++) { int y = 354 - li * 112; var row = c.Add(NewPanel(C_ROW, 20, y, 472, 100), root); // 🔴 行内元素全部挂 row(行内坐标),挂 root 会叠到面板角落看不见 string ic = Img(icons[li]); if (ic != "0") c.Add(NewImage(row, ic, 14, 52, 36, 36)); else c.Add(NewPanel(lineColors[li], 14, 52, 36, 36), row); c.Add(NewLabel(LineName(li), 13, C_WHITE, TextAnchor.MiddleLeft, true, 60, 74, 100, 18), row); c.Add(NewLabel($"Lv {GetLevel(pd, li)}/{_cfg.Upgrade.MaxLevel}", 11, C_GOLD, TextAnchor.MiddleLeft, true, 60, 56, 130, 16), row); for (int k = 0; k < _cfg.Upgrade.MaxLevel; k++) c.Add(NewPanel(k < GetLevel(pd, li) ? C_ORANGE : C_SUB, 60 + k * 14, 40, 11, 7), row); int curMax = LineCap(pd, li); int baseMax = (int)LineBase(li); float curVal = li == 0 ? player.health : li == 1 ? player.metabolism.hydration.value : player.metabolism.calories.value; float frac = Mathf.Clamp01(curVal / Mathf.Max(1f, curMax)); // 当前值进度条 c.Add(NewPanel(C_TITLE, 60, 14, 190, 20), row); c.Add(NewPanel(lineColors[li], 61, 15, (int)(188 * frac), 18), row); c.Add(NewLabel($"{(int)curVal}/{curMax}", 10, C_WHITE, TextAnchor.MiddleCenter, true, 60, 14, 190, 20), row); c.Add(NewLabel($"原版 {baseMax}", 9, C_GRAY, TextAnchor.MiddleLeft, false, 258, 14, 90, 16), row); float bless = GetBless(pd, li); int target = Mathf.Min(GetLevel(pd, li) + 1, _cfg.Upgrade.MaxLevel); float rate = target <= _cfg.Upgrade.SafeLevel ? 100f : Mathf.Clamp(_cfg.Gem.Tiers[sel].Rate + bless, 0f, 100f); // 🔴 祝福/成功率放在 Lv 行右侧空位(y58~74):原来 y32~48 会和 20 格等级条 // (y40~47,x60~337)纵向重叠、还擦到进度条(y14~34)——用户截图实锤 c.Add(NewLabel($"祝福{bless:F0}% 成功{rate:F0}%", 9, C_GRAY, TextAnchor.MiddleLeft, false, 258, 58, 126, 16), row); // 右侧操作列 c.Add(NewButton($"{CMD} bagup {keys[li]}", C_ORANGE, "强 化", 13, C_WHITE, true, 390, 50, 74, 38), row); for (int i = 0; i < _cfg.Gem.Tiers.Count && i < 4; i++) c.Add(NewButton($"{CMD} tier {i}", i == sel ? TierColor[i] : C_SUB, i == sel ? "●" : "", 8, C_WHITE, false, 392 + i * 19, 32, 15, 15), row); c.Add(NewButton($"{CMD} bless {li}", C_GOLD, "祝福", 9, "0.06 0.05 0.05 1", true, 390, 10, 37, 18), row); c.Add(NewButton($"{CMD} guireset {li}", C_RED, "洗点", 9, C_WHITE, true, 429, 10, 37, 18), row); } var bp = c.Add(NewPanel(C_ROW, 20, 20, 472, 84), root); c.Add(NewLabel("祝福 = 吃 1 颗紫星(成功率 +8%,上限 80%)· 洗点 = 吃 1 瓶红药(该线归零返宝石)", 10, C_GRAY, TextAnchor.MiddleLeft, false, 14, 58, 450, 18), bp); c.Add(NewLabel("彩点 = 投入品质(绿/蓝/红/黄),黄=完美必成 · 强化自动从背包扣 3 颗", 10, C_GOLD, TextAnchor.MiddleLeft, false, 14, 36, 450, 18), bp); c.Add(NewLabel($"/tzhe 一键合成 · /tzreset hp|water|food 洗点 · 目标≤{_cfg.Upgrade.SafeLevel} 永不失败 · 重生/重连自动恢复", 10, C_GRAY, TextAnchor.MiddleLeft, false, 14, 8, 450, 18), bp); CuiHelper.AddUi(player, c); } #endregion #region // ===== 悬浮按钮 ===== private void ShowHover(BasePlayer player) { CuiHelper.DestroyUi(player, UI_HOVER); var c = new CuiElementContainer(); var root = c.Add(new CuiPanel { Image = { Color = C_BG }, RectTransform = { AnchorMin = "1 0.5", AnchorMax = "1 0.5", OffsetMin = "-66 318", OffsetMax = "-38 346" } }, "Overlay", UI_HOVER); c.Add(new CuiButton { Button = { Command = CMD + " mainui", Color = "0 0 0 0" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, Text = { Text = "强化", FontSize = 11, Font = FONT_B, Align = TextAnchor.MiddleCenter, Color = C_GOLD } }, root); CuiHelper.AddUi(player, c); } #endregion #region // ===== 采集/击杀掉落 ===== private void GiveDrop(BasePlayer player, int tier, string source) { if (!_authorized) return; // 🔴 未授权不掉宝石 if (player == null || !player.IsConnected) return; GiveGem(player, tier, _cfg.Drop.Amount); player.ChatMessage($"{_cfg.Gem.Tiers[tier].Name}宝石 ×{_cfg.Drop.Amount}({source}获得)"); } private void OnDispenserGather(ResourceDispenser dispenser, BaseEntity entity, Item item) { if (!_cfg.Drop.Enabled) return; var player = entity as BasePlayer; if (player == null) return; // 🔴 按短名精确分流,各矿各 roll 一次(旧版金属矿会先吃石头档再吃金属档=双 roll 双吃, // 且 Contains("hq") 永远匹配不到——HQM 短名是 metal.refinedmetal) string sn = item.info.shortname; float roll = UnityEngine.Random.Range(0f, 100f); if (sn == "metal.ore") { if (roll < _cfg.Drop.Metal) GiveDrop(player, 0, "金属矿"); } else if (sn == "stones") { if (roll < _cfg.Drop.Stone) GiveDrop(player, 0, "采矿"); } else if (sn == "sulfur.ore") { if (roll < _cfg.Drop.Sulfur) GiveDrop(player, 1, "硫磺矿"); } else if (sn == "wood") { if (roll < _cfg.Drop.Tree) GiveDrop(player, 0, "砍树"); } } private void OnCollectiblePickup(Item item, BasePlayer player, CollectibleEntity entity) { if (!_cfg.Drop.Enabled || player == null) return; if (UnityEngine.Random.Range(0f, 100f) < _cfg.Drop.Pick) GiveDrop(player, 1, "采集"); } private void OnEntityDeath(BaseCombatEntity entity, HitInfo info) { if (!_cfg.Drop.Enabled || entity == null) return; var killer = info?.InitiatorPlayer; if (killer == null || killer.IsNpc) return; if (entity is BasePlayer && (entity as BasePlayer).IsNpc) { if (UnityEngine.Random.Range(0f, 100f) < _cfg.Drop.Npc) GiveDrop(killer, 1, "击杀科学家"); } else if (entity is BaseAnimalNPC) { if (UnityEngine.Random.Range(0f, 100f) < _cfg.Drop.Animal) GiveDrop(killer, 0, "狩猎"); } } #endregion #region // ===== 命令 ===== // /tz = 打开按钮模式面板;/tz auth <码> = 填授权码(管理员) [ChatCommand("tz")] private void CmdTz(BasePlayer player, string cmd, string[] args) { if (args != null && args.Length > 0 && args[0].ToLowerInvariant() == "auth") { if (!player.IsAdmin) { player.ChatMessage("只有服务器管理员能填授权码(这是本插件的激活入口)"); return; } string code = args.Length > 1 ? args[1].Trim() : ""; if (string.IsNullOrEmpty(code)) { player.ChatMessage($"当前授权状态:{_authHint} 授权码 {MaskCode(CurrentCode())} (用法:/tz auth PL-XXXX-XXXX-XXXX)"); return; } if (_cfg.Authorize == null) _cfg.Authorize = new AuthData(); _cfg.Authorize.LicenseCode = code.ToUpperInvariant(); SaveConfig(); player.ChatMessage("正在校验授权码,结果会在这里告诉你…"); RequestVerify(code, player, true); return; } ShowMain(player); } // /tzui = 按钮模式面板 [ChatCommand("tzui")] private void CmdTzUi(BasePlayer player, string cmd, string[] args) => ShowMain(player); // /tzhe = 一键合成(3:1 逐级往上合) [ChatCommand("tzhe")] private void CmdMerge(BasePlayer player, string cmd, string[] args) { if (!EnsureAuthorized(player)) return; int total = 0; bool again = true; while (again) { again = false; for (int i = 0; i < _cfg.Gem.Tiers.Count - 1; i++) { ulong low = _cfg.Gem.Tiers[i].Skin; if (CountGems(player, low) >= 3) { TakeGems(player, low, 3); GiveGem(player, i + 1, 1); total++; again = true; } } } player.ChatMessage(total > 0 ? $"合成完成:共合成 {total} 次(3 颗低级 → 1 颗高级)" : "背包里没有够 3 颗的宝石可合成"); } // /tzreset = 洗点(需背包有红药);GUI 按钮同走 ResetLineCore [ChatCommand("tzreset")] private void CmdResetLine(BasePlayer player, string cmd, string[] args) { string key = args.Length > 0 ? args[0].ToLower() : ""; int line = key == "water" ? 1 : key == "food" ? 2 : key == "hp" ? 0 : -1; if (line < 0) { player.ChatMessage("用法:/tzreset hp|water|food(消耗背包 1 瓶洗点药剂)"); return; } ResetLineCore(player, line); } // tzqianghua.auth <码> = 填/查授权码(不带参数只看状态);tzqianghua.deauth = 注销 [ConsoleCommand("tzqianghua.auth")] private void CcAuth(ConsoleSystem.Arg arg) { var player = arg?.Connection?.player as BasePlayer; // RCON / 服务器控制台 → null string code = (arg != null && arg.Args != null && arg.Args.Length > 0) ? arg.GetString(0).Trim() : ""; if (string.IsNullOrEmpty(code)) { PrintAuthStatus(); if (player != null) player.ChatMessage("当前授权状态:" + _authHint + " 授权码 " + MaskCode(CurrentCode())); return; } if (player != null && !player.IsAdmin) { player.ChatMessage("只有服务器管理员能填授权码。"); return; } if (_cfg == null) _cfg = new ConfigData(); if (_cfg.Authorize == null) _cfg.Authorize = new AuthData(); _cfg.Authorize.LicenseCode = code.ToUpperInvariant(); SaveConfig(); Puts("[授权] 已写入配置,开始校验 " + MaskCode(code) + " …"); RequestVerify(code, player, player != null); } [ConsoleCommand("tzqianghua.deauth")] private void CcDeauth(ConsoleSystem.Arg arg) { var player = arg?.Connection?.player as BasePlayer; if (player != null && !player.IsAdmin) { player.ChatMessage("只有服务器管理员能注销授权。"); return; } if (_cfg != null && _cfg.Authorize != null) { _cfg.Authorize.LicenseCode = ""; SaveConfig(); } _authCache = new AuthCache(); SaveAuthCache(); _authorized = false; _authHint = "已注销(未授权)"; foreach (var p in BasePlayer.activePlayerList) { if (p == null || !p.IsConnected) continue; CuiHelper.DestroyUi(p, UI_MAIN); CuiHelper.DestroyUi(p, UI_HUD); CuiHelper.DestroyUi(p, UI_HOVER); ApplyBase(p); } Puts("[授权] 已注销:本地凭据与配置里的授权码都已清空,插件回到未授权状态。"); if (player != null) player.ChatMessage("已注销授权,插件回到未授权状态。"); } [ConsoleCommand(CMD)] private void CmdUi(ConsoleSystem.Arg arg) { var player = arg?.Connection?.player as BasePlayer; if (player == null) return; var s = Sess(player.userID); switch (arg.GetString(0, "")) { case "mainui": ShowMain(player); break; case "closeui": CuiHelper.DestroyUi(player, UI_MAIN); break; case "tier": s.SelTier = Mathf.Clamp(arg.GetInt(1, 0), 0, _cfg.Gem.Tiers.Count - 1); ShowMain(player); break; case "bagup": TryUpgradeFromBag(player, arg.GetString(1, "hp") == "water" ? 1 : arg.GetString(1, "hp") == "food" ? 2 : 0); ShowMain(player); break; case "bless": UseBlessStone(player, Mathf.Clamp(arg.GetInt(1, 0), 0, 2)); ShowMain(player); break; case "guireset": ResetLineCore(player, Mathf.Clamp(arg.GetInt(1, 0), 0, 2)); ShowMain(player); break; } } private BasePlayer FindPlayer(string nameOrId) { if (string.IsNullOrEmpty(nameOrId)) return null; if (nameOrId.Length == 17 && nameOrId.All(char.IsDigit)) { var byId = BasePlayer.FindAwakeOrSleeping(nameOrId) as BasePlayer; if (byId != null) return byId; } return BasePlayer.activePlayerList.FirstOrDefault( x => x.displayName.ToLower().Contains(nameOrId.ToLower())); } // qhua.give <玩家> <品质1-4> <数量> [ConsoleCommand("qhua.give")] private void CmdGive(ConsoleSystem.Arg arg) { var caller = arg?.Connection?.player as BasePlayer; if (caller != null && !caller.IsAdmin) return; var p = FindPlayer(arg.GetString(0, "")); if (p == null) { arg.ReplyWith("找不到玩家"); return; } int tier = Mathf.Clamp(arg.GetInt(1, 1), 1, _cfg.Gem.Tiers.Count); // 🔴 跟着配置条数走,防止品质被删后越界 int count = Mathf.Clamp(arg.GetInt(2, 10), 1, 10000); var item = ItemManager.CreateByName(_cfg.Gem.Carrier, count, _cfg.Gem.Tiers[tier - 1].Skin); if (item == null) { arg.ReplyWith("物品创建失败"); return; } p.GiveItem(item, BasePlayer.GiveItemReason.PickedUp); arg.ReplyWith($"OK: {p.displayName} ← {_cfg.Gem.Tiers[tier - 1].Name}宝石 ×{count}"); } // qhua.givestone <玩家> <数量> —— 发祝福石;qhua.givepotion <玩家> <数量> —— 发洗点药剂 [ConsoleCommand("qhua.givestone")] private void CmdGiveStone(ConsoleSystem.Arg arg) { var caller = arg?.Connection?.player as BasePlayer; if (caller != null && !caller.IsAdmin) return; var p = FindPlayer(arg.GetString(0, "")); int count = Mathf.Clamp(arg.GetInt(1, 5), 1, 1000); var item = ItemManager.CreateByName(_cfg.Gem.Carrier, count, _reservedBless); if (item == null || p == null) { arg.ReplyWith("失败"); return; } p.GiveItem(item, BasePlayer.GiveItemReason.PickedUp); arg.ReplyWith($"OK: {p.displayName} ← 祝福石 ×{count}"); } [ConsoleCommand("qhua.givepotion")] private void CmdGivePotion(ConsoleSystem.Arg arg) { var caller = arg?.Connection?.player as BasePlayer; if (caller != null && !caller.IsAdmin) return; var p = FindPlayer(arg.GetString(0, "")); int count = Mathf.Clamp(arg.GetInt(1, 3), 1, 1000); var item = ItemManager.CreateByName(_cfg.Gem.Carrier, count, _reservedReset); if (item == null || p == null) { arg.ReplyWith("失败"); return; } p.GiveItem(item, BasePlayer.GiveItemReason.PickedUp); arg.ReplyWith($"OK: {p.displayName} ← 洗点药剂 ×{count}"); } // qhua.set <玩家> <等级> [ConsoleCommand("qhua.set")] private void CmdSet(ConsoleSystem.Arg arg) { var caller = arg?.Connection?.player as BasePlayer; if (caller != null && !caller.IsAdmin) return; var p = FindPlayer(arg.GetString(0, "")); if (p == null) { arg.ReplyWith("找不到玩家"); return; } string key = arg.GetString(1, "hp"); int lv = Mathf.Clamp(arg.GetInt(2, 0), 0, _cfg.Upgrade.MaxLevel); var pd = GetData(p.userID); SetLevel(pd, key == "water" ? 1 : key == "food" ? 2 : 0, lv); SaveData(); Apply(p); arg.ReplyWith($"OK: {p.displayName} {key} → Lv.{lv}"); } // qhua.wipe 确认 [ConsoleCommand("qhua.wipe")] private void CmdWipe(ConsoleSystem.Arg arg) { var caller = arg?.Connection?.player as BasePlayer; if (caller != null && !caller.IsAdmin) return; if (arg.GetString(0, "") != "确认") { arg.ReplyWith("⚠ 将清空全部玩家体质等级,上线时属性自动归位。确认执行: qhua.wipe 确认"); return; } _data.Clear(); SaveData(); foreach (var p in BasePlayer.activePlayerList) Apply(p); arg.ReplyWith("OK: 已清空全部体质数据并重置在线玩家属性"); } #endregion } }