using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries; // v1.1.1:webrequest 的 RequestMethod 枚举在这个命名空间下 using Oxide.Core.Plugins; using Oxide.Game.Rust.Cui; using UnityEngine; /* * LoginRewards v1.2.0 — 薯片 * --------------------------------------------------------------------------- * 每日登录签到奖励插件(7 天递增循环) * * v1.2.0 日系界面 + 背景图修好: * ★ 界面换成日系粉嫩配色(圆角卡片,六段式分区保留),自带樱花背景图 ui/bg_sakura.png * ★ 修好背景图功能(v1.1.3 之前 BackgroundUrl 一直不生效): * 旧代码等 ImageLibrary 的 IsReady,而 IsReady =「下载队列清空」, * 离线服或图片多的服它永远 false → 背景永远注册不上、永远出不来。 * v1.2.0 改走本地图:BackgroundUrl 填 local:ui/xxx.png 时,直接读 * oxide/data/LoginRewards/ 下的图片用 AddImageData 注册,不再等 ImageLibrary 就绪。 * ★ 换背景图不用动代码:换掉 ui/ 里的图片 + 改配置里的文件名即可 * * v1.1.1 修白块 + 加插件授权(软锁): * ★ 图片白块根因(与 GiftCenter v1.0.0 同一个病):CuiRawImageComponent 的 * Sprite 字段只吃「游戏内置资源路径」,而 ImageLibrary.GetImage() 返回的是 * FileStorage 的 crc 十进制串(如 4042659401),必须放 Png(json 键 "png")。 * 放错不报错、不崩服,只在**客户端** output_log.txt 留一行 * [FileSystem] Not Found: 4042659401 (UnityEngine.Texture) * 画面上就是一块白块。本版把面板背景图与奖励图标两处全改走 RawImg() 自动分流 * (crc → Png,http(s) 直链 → Url),并把 ImageLibrary 的 "LOADING_*" 占位 * 也一并降级成文字,避免下载期间闪白块。 * ★ 配置自愈:某天的 shortname 在游戏里不存在时(例如旧配置里的 crate_normal), * 自动用默认奖励顶替并在控制台说明,玩家不会「领到空气」。 * ★ 插件授权码(软锁):照 StackMaster v1.3.0 的授权模块移植。 * 未授权时插件照常加载、不报错、不卸载,命令照常注册(服主能打 * loginrewards.auth <码> 自救),只锁住「打开面板 / 领取奖励」两个入口; * 管理员会看到激活说明页,普通玩家静默拒绝。 * 绑定台数由授权服务决定(卖的时候设 2 台);离线宽限 3 天(每 6 小时复核一次)。 * * 授权:控制台/RCN loginrewards.auth PL-XXXX-XXXX-XXXX(不带参数只看状态) * 聊天框 /login auth PL-XXXX-XXXX-XXXX * 或填 config「授权 Authorize → 插件授权码 LicenseCode」 * * v1.1.0 界面重排: * - 面板 640x380 -> 860x520,新增 config「面板缩放 PanelScale」(0.7~1.4) 整体等比缩放 * - 六段式分区(标题栏/统计条/卡片行/信息条/按钮/底注),所有元素不再重叠 * - 「已领取」改为整卡压暗 + 带底衬的印章(旧版把文字压在图标的正中间,糊成一团) * - 图标改用游戏内置物品图(本地资源、不联网);只有显式配了 IconUrl 才走 ImageLibrary * * 功能: * - 进服自动弹出居中 GUI 面板(带鼠标光标,点按钮即可操作) * - 7 天递增奖励,连续签到中断则重新从第 1 天开始 * - 每条奖励支持:shortname / 数量 / 工坊皮肤ID / 自定义图标URL / 显示名 * - VIP 权限倍数加成(loginrewards.vip),普通玩家面板显示 VIP 对比 * - 整面板自定义背景图(config BackgroundUrl,不填 = 深色纯色) * - 图标三级来源:官方物品图标(自动) / 自定义图标URL / 工坊皮肤物品图 * - 领取时全服广播(内置,无需其他插件) * - /login 随时重开面板;背包满自动掉落脚下防丢 * - /loginadmin view|reset 管理命令(loginrewards.admin) * * 依赖:ImageLibrary(负责图标缓存;v1.2.0 起背景图走本地图,不再等它的就绪状态) * 存储:oxide/data/LoginRewards.json(玩家签到记录) * 配置:oxide/config/LoginRewards.json(奖励清单/面板设置,改完 oxide.reload) * --------------------------------------------------------------------------- */ namespace Oxide.Plugins { [Info("每日签到 LoginRewards", "薯片", "1.2.0")] [Description("Daily login rewards: 7-day escalating cycle, centered GUI panel, VIP multiplier, custom icons/background, workshop skins")] public class LoginRewards : RustPlugin { [PluginReference] private Plugin ImageLibrary; private const string PanelName = "loginrewards.panel"; private const string PermissionVip = "loginrewards.vip"; private const string PermissionAdmin = "loginrewards.admin"; private const string BackgroundKey = "loginrewards.background"; private const int CycleDays = 7; // ---- 插件授权(v1.1.1 新增)---- private const string AuthApiBase = "http://lic.biubr.com"; // 授权服务地址,换服只改这一处 private const string AuthPluginId = "loginrewards"; // 必须与后台插件清单的 plugin_id 一致 private const string AuthCacheFile = "LoginRewards.auth"; // -> oxide/data/LoginRewards.auth.json private const float AuthRetrySeconds = 300f; // 未授权时每 5 分钟自动重试 private const float AuthRenewHours = 6f; // ★ v1.1.3:常驻复核间隔,每 6h 主动问一次服务器 private const float AuthNudgeSeconds = 600f; // 未授权时往控制台写提醒的间隔 // 布局基准尺寸(像素,屏幕居中);实际尺寸 = 基准 × config 的「面板缩放 PanelScale」 private const float BaseW = 860f; private const float BaseH = 520f; #region Fields private PluginConfig _config; private Dictionary _playerData = new Dictionary(); private readonly HashSet _popupShown = new HashSet(); private int _registerAttempts; // ---- 授权运行时状态(v1.1.1)---- private bool _unloaded; // 已卸载,异步回调直接丢弃 private bool _authorized; // 当前是否已授权(false 时功能入口全部拦住) private bool _authPending; // 正在联网校验中,避免重复请求 private AuthCache _authCache; // 本地授权缓存 private string _authHint = "尚未校验"; // 面板上显示的授权状态说明 private string _latestVersion = ""; // 后台登记的最新版本 private string _updateHint = ""; // 版本落后提示 private int _nudgeCount; // 未授权提醒次数(上限 3 次) #endregion #region Config private class PluginConfig { [JsonProperty("设置 Settings")] public SettingsData Settings = new SettingsData(); [JsonProperty("奖励清单 Rewards (Day 1-7)")] public List Rewards = new List(); [JsonProperty("授权 Authorize")] public AuthData Authorize = new AuthData(); } // v1.1.1 插件授权配置。授权码留空 = 未授权(面板只显示激活说明页)。 private class AuthData { [JsonProperty("插件授权码 LicenseCode")] public string LicenseCode = ""; [JsonProperty("离线宽限天数 OfflineGraceDays")] public int OfflineGraceDays = 3; // ★ v1.1.3:7 -> 3,只在「连不上服务器」时才启用 [JsonProperty("启动时检查新版本 CheckUpdate")] public bool CheckUpdate = true; } private class SettingsData { [JsonProperty("面板标题 PanelTitle")] public string PanelTitle = "每日登录奖励"; [JsonProperty("背景图URL BackgroundUrl (留空 = 深色纯色)")] public string BackgroundUrl = ""; [JsonProperty("进服自动弹面板 PopupOnConnect")] public bool PopupOnConnect = true; [JsonProperty("弹面板延迟秒数 PopupDelaySeconds")] public float PopupDelaySeconds = 5f; [JsonProperty("领取时全服广播 BroadcastOnClaim")] public bool BroadcastOnClaim = true; [JsonProperty("广播内容 BroadcastMessage ({player}/{day}/{amount}/{item})")] public string BroadcastMessage = "{player} 领取了第 {day} 天登录奖励!"; [JsonProperty("VIP奖励倍数 VipMultiplier")] public float VipMultiplier = 2f; [JsonProperty("面板缩放 PanelScale (0.7 - 1.4,1 = 860x520)")] public float PanelScale = 1f; } private class RewardItem { [JsonProperty("天数 Day (1-7)")] public int Day = 1; [JsonProperty("物品 Shortname")] public string Shortname = "wood"; [JsonProperty("数量 Amount")] public int Amount = 500; [JsonProperty("皮肤ID SkinID (0 = 原版)")] public ulong SkinID = 0UL; [JsonProperty("图标URL IconUrl (留空 = 自动取官方物品图标)")] public string IconUrl = ""; [JsonProperty("显示名 DisplayName")] public string DisplayName = "木头"; } private class PlayerData { [JsonProperty("Streak")] public int Streak; [JsonProperty("TotalClaims")] public int TotalClaims; [JsonProperty("LastClaimDate")] public string LastClaimDate = ""; } private PluginConfig BuildDefaultConfig() { return new PluginConfig { Settings = new SettingsData(), Rewards = new List { new RewardItem { Day = 1, Shortname = "wood", Amount = 500, DisplayName = "木头" }, new RewardItem { Day = 2, Shortname = "stones", Amount = 1000, DisplayName = "石头" }, new RewardItem { Day = 3, Shortname = "metal.ore", Amount = 500, DisplayName = "金属矿石" }, new RewardItem { Day = 4, Shortname = "sulfur.ore", Amount = 500, DisplayName = "硫磺矿石" }, new RewardItem { Day = 5, Shortname = "cloth", Amount = 200, DisplayName = "布" }, new RewardItem { Day = 6, Shortname = "scrap", Amount = 100, DisplayName = "废金属" }, new RewardItem { Day = 7, Shortname = "box.wooden.large", Amount = 1, DisplayName = "大型物资箱" } } }; } protected override void LoadDefaultConfig() { _config = BuildDefaultConfig(); SaveConfig(); } protected override void LoadConfig() { base.LoadConfig(); try { _config = Config.ReadObject(); if (_config == null) throw new Exception("null config"); if (_config.Settings == null) _config.Settings = new SettingsData(); if (_config.Rewards == null || _config.Rewards.Count == 0) throw new Exception("no rewards"); if (_config.Authorize == null) _config.Authorize = new AuthData(); // v1.1.1:老配置没有这一段 } catch (Exception ex) { PrintWarning($"配置文件无效({ex.Message}),已重建默认配置"); _config = BuildDefaultConfig(); } SaveConfig(); } protected override void SaveConfig() => Config.WriteObject(_config); #endregion #region Lifecycle private void OnServerInitialized() { permission.RegisterPermission(PermissionVip, this); permission.RegisterPermission(PermissionAdmin, this); LoadData(); // v1.1.1:先把配置里「不存在的物品」自愈掉,再报剩余问题 HealInvalidRewards(); // 校验配置里的 shortname,防手误 foreach (var r in _config.Rewards) { if (string.IsNullOrEmpty(r.Shortname) || ItemManager.FindItemDefinition(r.Shortname) == null) PrintWarning($"配置警告:第 {r.Day} 天的 shortname '{r.Shortname}' 无效,玩家将领不到该奖励"); } RegisterImages(); // v1.1.1:授权判定(软锁)。没授权时下面所有功能入口都不会放行。 InitAuthorize(); // 只提醒不拦:落后于后台登记版本就在控制台提示 timer.Once(10f, CheckPluginVersion); } // 配置自愈:shortname 在游戏里不存在的奖励项,用默认配置里同一天的奖励顶替。 // 只动「物品不存在」的项 —— 服主自己改过的、有效的配置一律不碰。 // ★ 必须放在 OnServerInitialized 之后调用:LoadConfig 阶段 ItemManager 还没就绪。 private void HealInvalidRewards() { if (_config == null || _config.Rewards == null || _config.Rewards.Count == 0) return; var def = BuildDefaultConfig(); int fixedCount = 0; foreach (var r in _config.Rewards) { if (!string.IsNullOrEmpty(r.Shortname) && ItemManager.FindItemDefinition(r.Shortname) != null) continue; var alt = def.Rewards.FirstOrDefault(x => x.Day == r.Day); if (alt == null || alt.Shortname == r.Shortname) continue; PrintWarning($"配置自愈:第 {r.Day} 天的物品 '{r.Shortname}' 在游戏里不存在,已自动改用 '{alt.Shortname}'" + $"({alt.DisplayName})—— 想改成别的物品请编辑配置后 oxide.reload LoginRewards"); r.Shortname = alt.Shortname; if (string.IsNullOrEmpty(r.DisplayName) || r.DisplayName == "?") r.DisplayName = alt.DisplayName; fixedCount++; } if (fixedCount > 0) { SaveConfig(); PrintWarning($"配置自愈完成:共修正 {fixedCount} 个无效物品,oxide/config/LoginRewards.json 已更新"); } } private void Unload() { _unloaded = true; // 异步回调(联网校验 / 版本查询)到此直接丢弃 foreach (var p in BasePlayer.activePlayerList) CuiHelper.DestroyUi(p, PanelName); SaveData(); } // ImageLibrary 晚于本插件加载时自动补注册 private void OnPluginLoaded(Plugin plugin) { if (plugin != null && plugin.Name == "ImageLibrary") RegisterImages(); } #endregion #region Data private void LoadData() { try { _playerData = Interface.Oxide.DataFileSystem.ReadObject>("LoginRewards") ?? new Dictionary(); } catch (Exception) { PrintWarning("数据文件读取失败,使用空数据"); _playerData = new Dictionary(); } } private void SaveData() => Interface.Oxide.DataFileSystem.WriteObject("LoginRewards", _playerData); private PlayerData EnsureData(ulong userId) { PlayerData d; if (!_playerData.TryGetValue(userId, out d)) { d = new PlayerData(); _playerData[userId] = d; } return d; } private static string NowDateString() => DateTime.Now.ToString("yyyy-MM-dd"); private RewardItem GetReward(int index) // index 0-6 { return _config.Rewards.FirstOrDefault(x => x.Day == index + 1); } #endregion #region Images (ImageLibrary) private void RegisterImages() { // ★ v1.2.1 修复:删掉 IsReady 门卫。 // ImageLibrary 2.0.67 的 IsReady = (loadOrders.Count == 0 && !orderPending),依赖「下载队列清空」; // 本服队列有积压时它永远 false → 旧代码重试 30 次后静默放弃 → 背景图从来没注册成功过。 // 修仙 / 体质强化 / GiftCenter 均不检查此项直接注册,实测 AddImageData 立即可用,照抄该链路。 if (ImageLibrary == null) { PrintWarning("ImageLibrary 未加载,面板将没有背景图和自定义图标(功能不受影响)"); return; } // ① 预热「物品图标」:ImageLibrary 内置了全部物品的图标表,直接用 shortname 问它要, // 它会自己下载并缓存(首次拿到占位图,随后就是真图)。这里预先触发, // 等玩家进服弹面板时图标已经就绪,不会看到一片空白。 foreach (var r in _config.Rewards) { if (string.IsNullOrEmpty(r.Shortname)) continue; try { ImageLibrary.Call("GetImage", r.Shortname, 0UL); } catch (Exception ex) { PrintWarning($"预热物品图标失败 {r.Shortname}: {ex.Message}"); } } // ② 服主自定义图标 // ★★ AddImage 的签名是 (string url, string imageName, ulong imageId),**url 在前**! // 旧代码写成 (key, url, 0) → 键变成 "https://..._0",GetImage 永远查不到。 foreach (var r in _config.Rewards) { if (string.IsNullOrEmpty(r.IconUrl)) continue; try { ImageLibrary.Call("AddImage", r.IconUrl, GetIconKey(r.Day - 1), 0UL); } catch (Exception ex) { PrintWarning($"注册自定义图标失败 {GetIconKey(r.Day - 1)}: {ex.Message}"); } } if (!string.IsNullOrEmpty(_config.Settings.BackgroundUrl)) { try { var bu = _config.Settings.BackgroundUrl; if (bu.StartsWith("local:")) { // 本地文件:oxide/data/LoginRewards/<路径>(ImageLibrary 的 AddImage 只吃 http(s), // 本地图必须走 AddImageData —— 与修仙/体质强化已验证链路一致) var p = System.IO.Path.Combine(Interface.Oxide.DataDirectory, "LoginRewards", bu.Substring(6).TrimStart('/').Replace('/', System.IO.Path.DirectorySeparatorChar)); if (!System.IO.File.Exists(p)) { PrintWarning($"背景图文件不存在: {p}(DataDirectory={Interface.Oxide.DataDirectory})"); } else { ImageLibrary.Call("AddImageData", BackgroundKey, System.IO.File.ReadAllBytes(p), 0UL); Puts($"背景图已注册(本地 {p},{new System.IO.FileInfo(p).Length / 1024} KB)"); } } else { ImageLibrary.Call("AddImage", bu, BackgroundKey, 0UL); Puts($"背景图已注册(URL {bu})"); } } catch (Exception ex) { PrintWarning($"注册背景图失败: {ex}"); } } } private static string GetIconKey(int index) => $"loginrewards.day{index + 1}"; // 注:曾想用「游戏内置资源路径」assets/icons/.png 兜底,但 // `ItemDefinition.iconSprite` 返回的是 UnityEngine.Sprite(不是路径字符串,编译不过), // 而猜出来的路径无法在服务端验证真伪 —— 与其引入不确定性,不如由「物品名」文字层兜底: // 图标拿不到时名字和数量照样清楚。 // 取图片 sprite;拿不到返回 null(面板自动降级为文字) // ★ ImageLibrary 签名是 GetImage(string imageName, ulong imageId = 0, bool returnUrl = false) // —— 旧代码把 player.userID 传给了第三个参数(bool),类型不符 → 图标一直取不到。 // "NONE_x" = 没这张图(走文字降级);"LOADING_x" = 正在下载,照常用(下次刷新就是真图)。 private string GetSprite(string key) { if (ImageLibrary == null || string.IsNullOrEmpty(key)) return null; try { var result = ImageLibrary.Call("GetImage", key, 0UL); var sprite = result == null ? null : result.ToString(); if (string.IsNullOrEmpty(sprite) || sprite == "0" || sprite.StartsWith("NONE") || sprite.StartsWith("LOADING")) return null; return sprite; } catch { return null; } } #endregion // CUI 图片组件分流:crc(十进制数字)→ Png;http(s) 直链 → Url。 // ★★ 千万别再塞进 Sprite —— Sprite 只吃游戏内置资源路径, // 塞 crc 不会报错,只在客户端 output_log.txt 留一行 [FileSystem] Not Found,画面白块。 private static CuiRawImageComponent RawImg(string v, string color, float fade) { var r = new CuiRawImageComponent { Color = color, FadeIn = fade }; if (!string.IsNullOrEmpty(v) && (v.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || v.StartsWith("https://", StringComparison.OrdinalIgnoreCase))) r.Url = v; else r.Png = v; return r; } // ---- 未授权时的激活说明页(软锁:不报错、不卸载,只讲怎么激活)---- private void ShowAuthPanel(BasePlayer player) { if (player == null || !player.IsConnected) return; CuiHelper.DestroyUi(player, PanelName); float s = Scale(); const float AW = 720f; const float AH = 400f; float W = AW * s, H = AH * s; var c = new CuiElementContainer(); c.Add(new CuiPanel { Image = { Color = "0.99 0.955 0.965 0.98" }, RectTransform = { AnchorMin = "0.5 0.5", AnchorMax = "0.5 0.5", OffsetMin = $"{-W / 2f} {-H / 2f}", OffsetMax = $"{W / 2f} {H / 2f}" }, CursorEnabled = true }, "Overlay", PanelName); c.Add(new CuiLabel { Text = { Text = _config.Settings.PanelTitle + " · 尚未授权", FontSize = F(18, s), Align = TextAnchor.MiddleLeft, Color = "0.92 0.46 0.60 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(34, 332, s), OffsetMax = P(686, 372, s) } }, PanelName); c.Add(new CuiLabel { Text = { Text = "当前状态:" + _authHint, FontSize = F(12, s), Align = TextAnchor.MiddleLeft, Color = "0.74 0.80 0.86 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(34, 302, s), OffsetMax = P(686, 328, s) } }, PanelName); string[] lines = { "第 1 步 把服务器的公网 IP 发给作者,拿到属于你的授权码(形如 PL-XXXX-XXXX-XXXX)", "第 2 步 服务器控制台 / RCON / F1 执行: loginrewards.auth PL-XXXX-XXXX-XXXX", "第 3 步 看到「授权成功」后执行 oxide.reload LoginRewards ,功能立刻全部生效", "也可以把码填进配置文件「授权 Authorize → 插件授权码 LicenseCode」再重载插件。", "", "一个授权码可以绑 2 台服务器(主服 + 测试服);要换服请联系作者解绑旧的那台。", "授权成功后本机会记一份凭据,3 天内即使断网、或授权服务临时不通,插件照常工作。" }; float y = 264f; foreach (var ln in lines) { if (!string.IsNullOrEmpty(ln)) c.Add(new CuiLabel { Text = { Text = ln, FontSize = F(12, s), Align = TextAnchor.MiddleLeft, Color = "0.46 0.39 0.43 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(40, y - 22, s), OffsetMax = P(688, y, s) } }, PanelName); y -= 30f; } c.Add(new CuiButton { Button = { Command = "loginrewards.close", Color = "0.81 0.71 0.85 0.95" }, Text = { Text = "关闭", FontSize = F(14, s), Align = TextAnchor.MiddleCenter, Color = "0.46 0.28 0.35 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(290, 26, s), OffsetMax = P(430, 66, s) } }, PanelName); c.Add(new CuiLabel { Text = { Text = "买了码用不了?加作者微信 shupian1315,把服务器 IP 和授权码发过来", FontSize = F(11, s), Align = TextAnchor.MiddleCenter, Color = "0.72 0.60 0.67 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(26, 6, s), OffsetMax = P(694, 26, s) } }, PanelName); CuiHelper.AddUi(player, c); } // ===================================================================== // ===================================================================== // v1.1.3 授权撤销改造(★ 全项目统一标准,以后新建插件照这个来) // · ★ 加【常驻复核】:每 6 小时主动联网问一次。旧版只在 OnServerInitialized // 判断一次,且距上次成功不足 24h 连请求都不发 -> 作者在后台停用/删码之后, // 插件靠本地宽限能一直跑到过期,撤销最长要等 7 天,等于撤不掉。 // · 离线宽限 7 -> 3 天,且【只在「连不上服务器」时生效】;服务端一旦【明确拒绝】 // (码不存在 / 已停用 / 不是本插件的码 / 台数已满)→ 当场锁,一秒宽限都不给。 // · 服务端明确拒绝时抹掉本地凭据的 last_ok(Code 保留)→ 码恢复正常能自动复活。 // · CacheFresh 加两项:「配置里的码 ≠ 本地凭据的码 -> 旧凭据作废」+ 防系统时间回拨 // (当前时间比上次成功早 6h 以上 = 有人在拨表 -> 不认宽限,强制联网复核)。 // · 新增 loginrewards.deauth 注销命令(清本地凭据 + 清 config 里的码,立刻回未授权)。 // ===================================================================== #region 授权(插件授权码 · 联网校验 · 离线宽限) // ===================================================================== // 【软锁】策略(与《_插件产品化规范与售卖流程.md》§2.4 一致): // · 未授权时插件【照常加载、不报错、不卸载】,命令照常注册 —— // 这样服主能直接用 loginrewards.auth <码> 自救,不会卡在"卸载了就没法激活"的死循环。 // · 只在「功能入口」拦一道(EnsureAuthorized),未授权时只给管理员看激活说明页, // 普通玩家静默拒绝(不刷屏、不报红字,玩家看不出服务器有问题)。 // · 联网校验走 webrequest 异步回调,绝不阻塞服务器主线程。 // · 离线宽限 3 天:作者服务器挂了 / 买家断网,不能把买家的服搞停。 // · 一个授权码能绑几台服务器由授权服务(plugcodes.max_slots)说了算, // 插件侧只负责把服务端拒绝的原因(含「已经绑满 N 台」)原样翻成中文告诉服主。 // ===================================================================== // ---- 本地授权缓存:oxide/data/LoginRewards.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.1.3 起默认值改成 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(AuthNudgeSeconds, AuthNudge); // 未授权时提醒在线管理员 timer.Every(AuthRenewHours * 3600f, AuthRenew); // ★ v1.1.3:常驻复核,撤销最长 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("或在服务器控制台执行:loginrewards.auth <你的授权码>,然后 oxide.reload LoginRewards。"); 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); } // 未授权时每隔一段时间往控制台写一条提醒(最多 3 次;★ 不再发聊天消息) private void AuthNudge() { if (_unloaded || _authorized) return; if (_nudgeCount++ >= 3) return; // ★ 2026-09-16 起只写服务器控制台,不进任何人的聊天框: // 薯片反馈三个插件的未授权提示会连着刷聊天框,看着像服务器报错。 // 插件加载时已有完整中文提示;管理员真要用功能时也会弹激活说明面板, // 所以这里只当"后台日志"留个痕迹就够了。 Puts("★ 本插件尚未授权,功能不可用(" + _authHint + ")。" + "激活:控制台执行 loginrewards.auth PL-XXXX-XXXX-XXXX 后重载插件"); } // ---- 联网校验 ---- // 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, "还没有授权码。用法:loginrewards.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; _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) SetUpdateHint(_latestVersion); _authHint = "已授权"; Puts("[授权] 校验通过,插件已生效。授权码 " + MaskCode(code) + ",本地凭据有效期至 " + GraceExpireText() + "。"); 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 + " 天宽限期内,插件继续正常工作。"); 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,且买的是本插件;" + "重新校验:控制台执行 loginrewards.auth <码>。"); if (manual != null) Msg(manual, "授权失败:" + reason); // 授权掉了就把所有人的面板收掉(不能留一个点不动的空壳界面) foreach (var p in BasePlayer.activePlayerList) if (p != null && p.IsConnected) CuiHelper.DestroyUi(p, PanelName); timer.Once(AuthRetrySeconds, RetryAuth); } private void PrintAuthStatus() { Puts("===== 每日签到 LoginRewards 授权状态 ====="); Puts(" 状态:" + (_authorized ? "已授权(功能正常)" : "未授权(功能不生效)")); Puts(" 授权码:" + MaskCode(CurrentCode())); Puts(" 插件版本:" + Version.ToString() + (string.IsNullOrEmpty(_latestVersion) ? "" : ("(后台最新 " + _latestVersion + ")"))); Puts(" 本地凭据:" + (CacheFresh() ? ("有效,至 " + GraceExpireText()) : "无 / 已过期")); Puts(" 主动复核:每 " + AuthRenewHours + " 小时一次;仅「连不上服务器」时才用 " + GraceDays + " 天宽限"); Puts(" 用法:loginrewards.auth (不带参数 = 只看状态)"); Puts(" loginrewards.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() + "。功能不受影响,方便时更新即可。"); } // ---- ★ 软锁统一入口:返回 true 才继续执行功能 ---- // 未授权时:管理员弹激活说明页(服主一眼知道怎么救),普通玩家静默拒绝。 private bool EnsureAuthorized(BasePlayer player) { if (_authorized) return true; if (player != null && IsAdmin(player)) ShowAuthPanel(player); return false; } // ---- 聊天回显用(带插件前缀)---- private void Msg(BasePlayer player, string text) { if (player == null) { Puts(text); return; } SendReply(player, "[每日签到] " + text); } // ===================================================================== #endregion #region Hooks // 进服首次复活后弹面板(每次连接只弹一次,死亡重生不重复弹) private void OnPlayerRespawned(BasePlayer player) { if (player == null || !_config.Settings.PopupOnConnect) return; if (_popupShown.Contains(player.userID)) return; _popupShown.Add(player.userID); var target = player; timer.Once(_config.Settings.PopupDelaySeconds, () => { if (target != null && target.IsConnected) ShowPanel(target); }); } private void OnPlayerDisconnected(BasePlayer player, string reason) { if (player != null) _popupShown.Remove(player.userID); } #endregion #region Commands [ChatCommand("login")] private void CmdLogin(BasePlayer player, string command, string[] args) { // 授权码命令:未授权时也必须能用(这是买家的激活入口) if (args != null && args.Length > 0 && args[0].ToLowerInvariant() == "auth") { if (!IsAdmin(player)) { SendReply(player, "只有服务器管理员能填授权码(这是本插件的激活入口)"); return; } string code = args.Length > 1 ? args[1].Trim() : ""; if (string.IsNullOrEmpty(code)) { SendReply(player, $"当前授权状态:{_authHint} 授权码 {MaskCode(CurrentCode())} (用法:/login auth PL-XXXX-XXXX-XXXX)"); return; } if (_config.Authorize == null) _config.Authorize = new AuthData(); _config.Authorize.LicenseCode = code.ToUpperInvariant(); SaveConfig(); SendReply(player, "正在校验授权码,结果会在这里告诉你…"); RequestVerify(code, player, true); return; } ShowPanel(player); } [ChatCommand("loginadmin")] private void CmdLoginAdmin(BasePlayer player, string command, string[] args) { if (!IsAdmin(player)) { SendReply(player, "无权限:需要 loginrewards.admin"); return; } if (args == null || args.Length < 2) { SendReply(player, "用法:/loginadmin view <玩家> | /loginadmin reset <玩家>"); return; } var targetId = ResolveTargetId(args[1]); if (targetId == 0UL) { SendReply(player, $"找不到玩家:{args[1]}(在线玩家可用名字,离线玩家请用 SteamID64)"); return; } switch (args[0].ToLower()) { case "reset": _playerData.Remove(targetId); SaveData(); SendReply(player, $"已重置玩家 {targetId} 的签到数据(下次领取从第 1 天开始)"); break; case "view": PlayerData d; if (!_playerData.TryGetValue(targetId, out d) || d == null) { SendReply(player, $"玩家 {targetId} 暂无签到记录"); return; } SendReply(player, $"玩家 {targetId}:连续签到 {d.Streak} 天 | 累计 {d.TotalClaims} 次 | 最后领取 {d.LastClaimDate}"); break; default: SendReply(player, "未知子命令,可用:view / reset"); break; } } [ConsoleCommand("loginrewards.claim")] private void CcClaim(ConsoleSystem.Arg arg) { var player = arg?.Player(); if (player == null || !player.IsConnected) return; var error = ClaimReward(player); if (error != null) { SendReply(player, error); return; } CuiHelper.DestroyUi(player, PanelName); ShowPanel(player); // 刷新为已领状态 } [ConsoleCommand("loginrewards.close")] private void CcClose(ConsoleSystem.Arg arg) { var player = arg?.Player(); if (player != null) CuiHelper.DestroyUi(player, PanelName); } // 控制台 / RCON 授权入口:loginrewards.auth [授权码],不带参数只看状态 [ConsoleCommand("loginrewards.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(" 重新激活:loginrewards.auth PL-XXXX-XXXX-XXXX"); if (player != null) Msg(player, "已注销,插件回到未授权状态。"); } [ConsoleCommand("loginrewards.auth")] private void CcAuth(ConsoleSystem.Arg arg) { var player = arg != null ? arg.Player() : null; // 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) Msg(player, "当前授权状态:" + _authHint + " 授权码 " + MaskCode(CurrentCode())); return; } if (player != null && !IsAdmin(player)) { Msg(player, "只有服务器管理员能填授权码。"); return; } if (_config == null) _config = new PluginConfig(); if (_config.Authorize == null) _config.Authorize = new AuthData(); _config.Authorize.LicenseCode = code.ToUpperInvariant(); SaveConfig(); Puts("[授权] 已写入配置,开始校验 " + MaskCode(code) + " …"); RequestVerify(code, player, player != null); } private bool IsAdmin(BasePlayer player) { return player.IsAdmin || permission.UserHasPermission(player.UserIDString, PermissionAdmin); } private ulong ResolveTargetId(string input) { ulong id; if (ulong.TryParse(input, out id)) return id; var p = BasePlayer.Find(input); return p != null ? p.userID : 0UL; } #endregion #region Claim Logic // 返回 null = 成功;返回字符串 = 失败原因 private string ClaimReward(BasePlayer player) { // ★ 软锁:未授权一律不发奖励 if (!EnsureAuthorized(player)) return "本插件尚未授权,暂时不能领取奖励,请联系服主。"; var data = EnsureData(player.userID); var todayStr = NowDateString(); if (data.LastClaimDate == todayStr) return "今天已经领取过了,明天再来吧!"; var yesterdayStr = DateTime.Now.AddDays(-1).ToString("yyyy-MM-dd"); data.Streak = data.LastClaimDate == yesterdayStr ? data.Streak + 1 : 1; data.LastClaimDate = todayStr; data.TotalClaims++; int index = (data.Streak - 1) % CycleDays; var reward = GetReward(index); if (reward == null) { SaveData(); return $"第 {index + 1} 天的奖励未配置,请联系管理员"; } bool isVip = permission.UserHasPermission(player.UserIDString, PermissionVip); int amount = Mathf.Max(1, Mathf.RoundToInt(reward.Amount * (isVip ? _config.Settings.VipMultiplier : 1f))); GiveReward(player, reward, amount); SaveData(); SendReply(player, $"领取成功:{reward.DisplayName} ×{amount} 已放入背包"); if (_config.Settings.BroadcastOnClaim) { try { var msg = (_config.Settings.BroadcastMessage ?? "{player} 领取了第 {day} 天登录奖励!") .Replace("{player}", player.displayName) .Replace("{day}", (index + 1).ToString()) .Replace("{amount}", amount.ToString()) .Replace("{item}", string.IsNullOrEmpty(reward.DisplayName) ? reward.Shortname : reward.DisplayName); Server.Broadcast(msg); } catch (Exception ex) { PrintWarning($"广播失败: {ex.Message}"); } } return null; } private void GiveReward(BasePlayer player, RewardItem reward, int amount) { var def = ItemManager.FindItemDefinition(reward.Shortname); if (def == null) { PrintWarning($"配置错误:shortname '{reward.Shortname}' 不存在"); SendReply(player, $"配置错误:物品 {reward.Shortname} 不存在,本次奖励跳过"); return; } var item = ItemManager.CreateByName(reward.Shortname, amount, reward.SkinID); if (item == null) { PrintWarning("ItemManager.CreateByName 失败"); return; } if (player.inventory.GiveItem(item)) return; // 背包满 → 掉在脚下,防丢 item.Drop(player.GetDropPosition(), player.GetDropVelocity()); SendReply(player, $"背包已满,{reward.DisplayName} 已掉落在你脚下,记得捡起来"); } #endregion #region GUI // ===== 布局辅助:基准 860×520,按 config「面板缩放」整体等比缩放 ===== private float Scale() { float s = _config == null || _config.Settings == null ? 1f : _config.Settings.PanelScale; if (s <= 0f) s = 1f; return Mathf.Clamp(s, 0.7f, 1.4f); } private static string P(float x, float y, float s) => $"{x * s} {y * s}"; private static int F(int size, float s) => Mathf.Max(9, Mathf.RoundToInt(size * s)); private void ShowPanel(BasePlayer player) { if (player == null || !player.IsConnected) return; // ★ 软锁:未授权时不给功能面板(管理员看到激活说明页,普通玩家静默拒绝) if (!EnsureAuthorized(player)) return; CuiHelper.DestroyUi(player, PanelName); var data = EnsureData(player.userID); bool isVip = permission.UserHasPermission(player.UserIDString, PermissionVip); float mult = isVip ? _config.Settings.VipMultiplier : 1f; var todayStr = NowDateString(); var yesterdayStr = DateTime.Now.AddDays(-1).ToString("yyyy-MM-dd"); bool claimedToday = data.LastClaimDate == todayStr; int nextStreak = claimedToday ? data.Streak : (data.LastClaimDate == yesterdayStr ? data.Streak + 1 : 1); int todayIndex = claimedToday ? -1 : (nextStreak - 1) % CycleDays; // 当前可领 int tomorrowIndex = claimedToday ? data.Streak % CycleDays : -1; // 明日可领 int claimedUpTo = claimedToday ? (data.Streak - 1) % CycleDays : -1; // ===== 布局:基准 860×520,按「面板缩放」等比缩放 ===== // 六段式(y 为面板内绝对坐标,原点在左下): // 标题栏 430~494 | 统计条 372~416 | 卡片行 168~360 | 信息条 112~156 | 按钮 48~98 | 提示 16~40 float s = Scale(); float W = BaseW * s, H = BaseH * s; const float RowX0 = 26f, RowX1 = 834f, CardGap = 12f; float cardW = (RowX1 - RowX0 - CardGap * (CycleDays - 1)) / CycleDays; // ≈105.1 var c = new CuiElementContainer(); // ---------- 主面板 ---------- c.Add(new CuiPanel { Image = { Color = "0.99 0.955 0.965 0.97" }, RectTransform = { AnchorMin = "0.5 0.5", AnchorMax = "0.5 0.5", OffsetMin = $"{-W / 2f} {-H / 2f}", OffsetMax = $"{W / 2f} {H / 2f}" }, CursorEnabled = true }, "Overlay", PanelName); // ---------- 自定义背景图(可选,垫最底层)---------- var bgSprite = GetSprite(BackgroundKey); if (!string.IsNullOrEmpty(bgSprite)) { c.Add(new CuiElement { Parent = PanelName, Name = "loginrewards.bg", Components = { new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "1 1" }, RawImg(bgSprite, "1 1 1 0.94", 0.15f) } }); } // ---------- 1. 标题栏(26~834 / 430~494)---------- c.Add(new CuiPanel { Image = { Color = "1 0.87 0.90 0.85" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(26, 430, s), OffsetMax = P(834, 494, s) } }, PanelName); c.Add(new CuiLabel { Text = { Text = _config.Settings.PanelTitle, FontSize = F(17, s), Align = TextAnchor.MiddleLeft, Color = "0.46 0.27 0.35 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(46, 446, s), OffsetMax = P(520, 486, s) } }, PanelName); if (isVip) { c.Add(new CuiPanel { Image = { Color = "1 0.75 0.81 0.95" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(596, 451, s), OffsetMax = P(770, 483, s) } }, PanelName); c.Add(new CuiLabel { Text = { Text = $"VIP ×{mult:0.#} 加成已生效", FontSize = F(12, s), Align = TextAnchor.MiddleCenter, Color = "0.56 0.16 0.30 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(596, 451, s), OffsetMax = P(770, 483, s) } }, PanelName); } else { c.Add(new CuiPanel { Image = { Color = "1 1 1 0.6" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(596, 451, s), OffsetMax = P(770, 483, s) } }, PanelName); c.Add(new CuiLabel { Text = { Text = "VIP 可领双倍奖励", FontSize = F(12, s), Align = TextAnchor.MiddleCenter, Color = "0.78 0.83 0.89 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(596, 451, s), OffsetMax = P(770, 483, s) } }, PanelName); } c.Add(new CuiButton { Button = { Command = "loginrewards.close", Color = "0.93 0.47 0.58 0.95" }, Text = { Text = "X", FontSize = F(14, s), Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(784, 451, s), OffsetMax = P(816, 483, s) } }, PanelName); // ---------- 2. 统计条(26~834 / 372~416)---------- c.Add(new CuiPanel { Image = { Color = "1 1 1 0.62" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(26, 372, s), OffsetMax = P(834, 416, s) } }, PanelName); string statState = claimedToday ? $"明天可领第 {(data.Streak % CycleDays) + 1} 天" : $"今天可领第 {((nextStreak - 1) % CycleDays) + 1} 天"; string[] stats = { $"连续签到 {data.Streak} 天", $"累计领取 {data.TotalClaims} 次", statState }; float statW = (RowX1 - RowX0) / 3f; for (int i = 0; i < 3; i++) { float sx = RowX0 + statW * i; c.Add(new CuiLabel { Text = { Text = stats[i], FontSize = F(13, s), Align = TextAnchor.MiddleCenter, Color = i == 2 ? "0.88 0.38 0.52 1" : "0.78 0.83 0.89 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(sx, 380, s), OffsetMax = P(sx + statW, 408, s) } }, PanelName); } // ---------- 3. 7 天卡片(y 168~360,卡宽 ≈105)---------- // 卡内四层,彼此间距 ≥4px,谁都不压谁: // 数量 174~196 | 名称 200~222 | 图标 228~318 | 天数 330~358 for (int i = 0; i < CycleDays; i++) { var reward = GetReward(i); float x = RowX0 + i * (cardW + CardGap); bool isClaimed = claimedToday && i <= claimedUpTo; bool isToday = i == todayIndex; bool isTomorrow = i == tomorrowIndex; bool isLast = i == CycleDays - 1; string cardColor = isToday ? "1 0.95 0.84 0.95" : isLast ? "1 0.91 0.94 0.93" : "1 1 1 0.90"; if (isClaimed) cardColor = "0.94 0.91 0.92 0.88"; // 今日:外层描边(比卡片大 1.5px,做 1.5px 边框) if (isToday) { c.Add(new CuiPanel { Image = { Color = "0.94 0.56 0.68 0.95" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(x - 1.5f, 166.5f, s), OffsetMax = P(x + cardW + 1.5f, 361.5f, s) } }, PanelName); } c.Add(new CuiPanel { Image = { Color = cardColor }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(x, 168, s), OffsetMax = P(x + cardW, 360, s) } }, PanelName); // 天数标签 string dayText = $"第{reward?.Day ?? (i + 1)}天"; string dayColor = isLast ? "0.92 0.52 0.64 1" : "0.48 0.38 0.44 1"; if (isToday) { dayText = $"今日 第{reward?.Day ?? (i + 1)}天"; dayColor = "0.88 0.32 0.48 1"; } else if (isTomorrow) { dayText = $"明日 第{reward?.Day ?? (i + 1)}天"; dayColor = "0.58 0.58 0.92 1"; } else if (isClaimed) dayColor = "0.55 0.58 0.62 1"; c.Add(new CuiLabel { Text = { Text = dayText, FontSize = F(isToday || isTomorrow ? 12 : 13, s), Align = TextAnchor.MiddleCenter, Color = dayColor }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(x, 330, s), OffsetMax = P(x + cardW, 358, s) } }, PanelName); // 图标:服主自定义图 > 游戏内置物品图标 > 文字降级 string sprite = null; if (reward != null && !string.IsNullOrEmpty(reward.IconUrl)) sprite = GetSprite(GetIconKey(i)); // ① 服主自定义图 if (string.IsNullOrEmpty(sprite) && reward != null) sprite = GetSprite(reward.Shortname); // ② ImageLibrary 内置物品图标 // ③ 都没有 → 下面的「物品名」文字层照样把信息说清楚 const float iconSize = 90f; float iconX = x + (cardW - iconSize) / 2f; if (!string.IsNullOrEmpty(sprite)) { c.Add(new CuiElement { Parent = PanelName, Components = { new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(iconX, 228, s), OffsetMax = P(iconX + iconSize, 228 + iconSize, s) }, RawImg(sprite, isClaimed ? "0.55 0.55 0.55 0.55" : "1 1 1 1", 0.2f) } }); } else { c.Add(new CuiLabel { Text = { Text = reward?.DisplayName ?? "?", FontSize = F(13, s), Align = TextAnchor.MiddleCenter, Color = "0.46 0.37 0.43 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(x + 6, 228, s), OffsetMax = P(x + cardW - 6, 318, s) } }, PanelName); } // 物品名 c.Add(new CuiLabel { Text = { Text = reward?.DisplayName ?? "未配置", FontSize = F(12, s), Align = TextAnchor.MiddleCenter, Color = isClaimed ? "0.55 0.58 0.62 1" : "0.43 0.34 0.40 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(x + 4, 200, s), OffsetMax = P(x + cardW - 4, 222, s) } }, PanelName); // 数量 int shownAmount = reward == null ? 0 : Mathf.RoundToInt(reward.Amount * mult); string amountColor = isToday ? "0.88 0.32 0.48 1" : isClaimed ? "0.66 0.60 0.64 1" : "0.48 0.40 0.46 1"; c.Add(new CuiLabel { Text = { Text = reward == null ? "未配置" : $"×{shownAmount}", FontSize = F(14, s), Align = TextAnchor.MiddleCenter, Color = amountColor }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(x + 4, 174, s), OffsetMax = P(x + cardW - 4, 196, s) } }, PanelName); // 已领:★ 整卡压暗 + 带实心底衬的印章(不再把文字压在图标上) if (isClaimed) { c.Add(new CuiPanel { Image = { Color = "0.99 0.97 0.98 0.50" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(x, 168, s), OffsetMax = P(x + cardW, 360, s) } }, PanelName); float stampW = Mathf.Min(78f, cardW - 16f); float stampX = x + (cardW - stampW) / 2f; c.Add(new CuiPanel { Image = { Color = "0.77 0.89 0.74 0.97" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(stampX, 247, s), OffsetMax = P(stampX + stampW, 283, s) } }, PanelName); c.Add(new CuiLabel { Text = { Text = "已领取", FontSize = F(14, s), Align = TextAnchor.MiddleCenter, Color = "0.30 0.48 0.30 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(stampX, 247, s), OffsetMax = P(stampX + stampW, 283, s) } }, PanelName); } } // ---------- 4. 信息条(26~834 / 112~156)---------- int infoIndex = todayIndex >= 0 ? todayIndex : tomorrowIndex; var infoReward = infoIndex >= 0 ? GetReward(infoIndex) : null; string infoText; if (infoReward == null) { infoText = "奖励配置缺失,请联系管理员"; } else if (claimedToday) { int tAmount = Mathf.RoundToInt(infoReward.Amount * mult); infoText = $"今日已领取:{infoReward.DisplayName} ×{tAmount} | 明天记得再来!"; } else if (isVip) { int tAmount = Mathf.RoundToInt(infoReward.Amount * _config.Settings.VipMultiplier); infoText = $"VIP 加成 ×{_config.Settings.VipMultiplier:0.#} 已生效:今日 {infoReward.DisplayName} ×{tAmount}"; } else { int baseAmount = infoReward.Amount; int vipAmount = Mathf.RoundToInt(infoReward.Amount * _config.Settings.VipMultiplier); infoText = $"今日可领:{infoReward.DisplayName} ×{baseAmount} | VIP 可领 ×{vipAmount}"; } c.Add(new CuiPanel { Image = { Color = "1 1 1 0.62" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(26, 112, s), OffsetMax = P(834, 156, s) } }, PanelName); c.Add(new CuiLabel { Text = { Text = infoText, FontSize = F(13, s), Align = TextAnchor.MiddleCenter, Color = "0.48 0.38 0.44 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(32, 118, s), OffsetMax = P(828, 152, s) } }, PanelName); // ---------- 5. 按钮行(主按钮 180~540,关闭 556~680,高 50)---------- c.Add(new CuiButton { Button = { Command = "loginrewards.claim", Color = claimedToday ? "0.90 0.86 0.88 0.92" : "1 0.64 0.75 0.98" }, Text = { Text = claimedToday ? "今日已领取" : "领取今日奖励", FontSize = F(16, s), Align = TextAnchor.MiddleCenter, Color = claimedToday ? "0.62 0.56 0.60 1" : "1 1 1 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(180, 48, s), OffsetMax = P(540, 98, s) } }, PanelName); c.Add(new CuiButton { Button = { Command = "loginrewards.close", Color = "0.81 0.71 0.85 0.95" }, Text = { Text = "关闭", FontSize = F(14, s), Align = TextAnchor.MiddleCenter, Color = "0.46 0.28 0.35 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(556, 48, s), OffsetMax = P(680, 98, s) } }, PanelName); // ---------- 6. 底部提示 ---------- c.Add(new CuiLabel { Text = { Text = "聊天框输入 /login 可随时重新打开签到面板 | LoginRewards v" + Version.ToString(), FontSize = F(11, s), Align = TextAnchor.MiddleCenter, Color = "0.72 0.60 0.67 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = P(26, 16, s), OffsetMax = P(834, 40, s) } }, PanelName); CuiHelper.AddUi(player, c); } #endregion } }