using System; using System.Collections.Generic; using System.IO; // 内嵌素材用 File.ReadAllBytes / Path.Combine using System.Linq; using System.Text; // v1.0.3:写《★素材图放这里.txt》要用 UTF8Encoding(true) 带 BOM(记事本才不乱码) using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries; // v1.0.0:webrequest 的 RequestMethod 枚举在这个命名空间下(真机编译报过 name does not exist) using Oxide.Core.Plugins; using Oxide.Game.Rust.Cui; using UnityEngine; /* * GiftCenter 福利中心 v1.0.5 — 薯片 * --------------------------------------------------------------------------- * 一个界面里的四个福利页签(左侧竖向菜单 + 右侧内容区,1000x620): * ① 新服登录 服务器信息 + 规则 + 新手礼包(一人一次,可限开服 N 天内) * ② 签到好礼 7 天递增循环签到(由 LoginRewards v1.1.0 移植,老数据自动迁移) * ③ 在线礼包 按在线时长解锁档位,每日重置,掉线/重启不丢时长 * ④ 联系客服 客服 QQ / 微信 / QQ群 / 开服时间 / 二维码 / 赞助说明 * * 界面素材走 ImageLibrary 缓存后推给玩家(玩家不需要访问图床)。两条路线: * ① 图床路线:配好「素材图 Assets -> 图床前缀 ImageBaseUrl」(https://), * 素材 PNG 放图床,插件只存文件名 —— 换图不用重打包; * ② 内嵌路线:图床前缀留空,且 oxide/data/GiftCenter/assets/ 里有同名 PNG, * 就逐张走 ImageLibrary.AddImageData(),素材随插件包分发,离线可用, * 不需要证书、也没有 3 MB 上限。 * 两条都没有 = 自动降级为纯色玻璃风,功能一模一样,不会有任何报错。 * * 命令 /gift 开面板(/gift 0|1|2|3 直接跳页签) * /giftadmin view|reset|resetall|signin|online * 权限 giftcenter.vip 签到 + 在线礼包 双倍 * giftcenter.admin 管理命令 * 存储 oxide/data/GiftCenter/players.json * 配置 oxide/config/GiftCenter.json(改完 oxide.reload GiftCenter) * * ===== v1.0.3 新增(2026-09-15 下午 · 为"买家开箱体验")===== * ★ 启动时自动创建 oxide/data/GiftCenter/assets/,并写入《★素材图放这里.txt》 * (把 30 个文件名 + 用途 + 放好后做什么 全列出来)—— 买家不用问、不用猜; * ★ 缺图时控制台打醒目中文指引(完整路径 + 缺失清单),不再只丢一句"纯色玻璃风"; * ★ 新增控制台命令 giftcenter.assets —— 随时查"文件夹在哪 / 已有几张 / 缺哪几张"; * ★ ★ 默认「图床前缀 ImageBaseUrl」由 https://sprust.cn/dl/tool 改为【空】: * 老默认值是作者自己的域名,买家装上会去拉作者的图床,拉不到就只剩纯色。 * 现在默认走内嵌 assets 文件夹 —— 买家把随包素材拷进去就能用,零外部依赖。 * * ★ 三条素材硬约束(实测,别再试别的写法): * 1) ImageLibrary 只吃 PNG(源码 FileStorage.Type.png + texture.EncodeToPNG()),JPG 会裂图; * 2) Rust CUI 没有九宫格拉伸 → 带边框的图一律按最终像素 1:1 出图,绝不放大缩小; * 所以本插件所有素材坐标都写死了尺寸(背景 1000x620、菜单底板 150x82、横幅 320x116、 * 四角 64x64、立绘 200x316、插画 340x296、二维码 240x240、卡片图标 96x96)。 * 3) 图片模型出的图自带水印 → 出图阶段已用「裁切 / 抠底」消掉,插件侧不做任何处理。 * 4) ★ 图床必须是 HTTPS。ImageLibrary 源码 DownloadImage() 里有一句 * UnityWebRequest.Get(info.url.Replace("http://", "https://")) * 它会把 http:// 无条件改写成 https://,所以纯 HTTP 图床(含带端口那种) * 必然一张都拉不到,而且不报错,只在服务端日志里刷 download failed。 * 5) ★ 单张图 EncodeToPNG 后不得超过 3 MB(ImageLibrary 源码 3145728 字节判断, * 超了直接丢弃)。注意比的是 32 位 RGBA 重编码后的体积,不是源文件体积。 * 6) ★★★ v1.0.0 图片全白块的真正原因 —— CUI「图片」字段放错位置不报错,只白块: * Oxide 的 CuiRawImageComponent / CuiImageComponent 各有四个图片字段,语义完全不同: * · Sprite Sprite 资源路径(游戏内置贴图),如 assets/content/ui/ui.white.tga * → 必须配 CuiImageComponent * · Png FileStorage 图片 id(十进制的 32 位 crc 字符串) * → 必须配 CuiRawImageComponent.Png(JSON 键 "png") * · Url 图片直链 https://... * → 必须配 CuiRawImageComponent.Url(JSON 键 "url") * · ItemId 物品 ID(int),客户端直接用本地物品图标 * → 必须配 CuiImageComponent.ItemId(JSON 键 "itemid",零依赖最稳) * v1.0.0 把 crc 和 url 全塞进了 Sprite —— 客户端于是把它们当资源路径交给 FileSystem, * 只在客户端 output_log.txt 留一行 * [FileSystem] Not Found: 2781206807 (UnityEngine.Texture) * 画面上就是一块白块(RawImage 取不到 texture 时用 color 铺满)。 * ★ 另外 CuiElement 的 Name 是客户端 GameObject 字典的键,同一容器内必须唯一: * v1.0.0 的 30 张图共用一个 "giftcenter.img",会互相覆盖(见 MkImage/_imgSeq)。 * ★ 排查口诀:图不显示先看**客户端** output_log.txt 有没有 [FileSystem] Not Found, * 有就是字段放错;服务端 filerequestdebug 无记录说明客户端压根没发文件请求。 * * v1.0.2 新增「插件授权码」(软锁)—— 照 StackMaster v1.3.0 的授权模块移植: * · 未授权时插件【照常加载、不报错、不卸载】,命令照常注册 —— 服主能打 * giftcenter.auth <码> 自救,不会卡在"卸载了就没法激活"的死循环。 * · 只锁「打开面板 + 三处领取」四个入口;未授权时管理员看到激活说明页, * 普通玩家静默拒绝(不刷屏、不报红字,玩家看不出服务器有问题)。 * · 联网校验异步走 webrequest,不阻塞服务器主线程;离线宽限 3 天(v1.0.5 起,原 7 天) * (作者服务器挂了 / 买家断网,不能把买家的服搞停)。 * · 一个码能绑几台由授权服务说了算(卖的时候设 2 台 = 主服 + 测试服); * 插件侧只负责把服务端拒绝的原因(含「已经绑满 N 台」)原样翻成中文。 * · 授权:控制台/RCON giftcenter.auth PL-XXXX-XXXX-XXXX(不带参数只看状态) * 聊天框 /gift auth PL-XXXX-XXXX-XXXX * 或填 config「授权 Authorize → 插件授权码 LicenseCode」 * 注销:控制台/RCON giftcenter.deauth * * v1.0.5 授权撤销改造(★ 全项目统一标准,以后新建插件照这个来): * · ★ 加【常驻复核】:每 6 小时主动联网问一次。v1.0.4 及以前只在 OnServerInitialized * 判断一次,且距上次成功不足 24h 连请求都不发 -> 作者在后台停用/删码之后,插件靠 * 本地宽限能一直跑到过期,撤销最长要等 7 天,等于撤不掉。 * · 离线宽限 7 -> 3 天,且【只在「连不上服务器」时生效】;服务端一旦【明确拒绝】 * (码不存在 / 已停用 / 不是本插件的码 / 台数已满)→ 当场锁,一秒宽限都不给。 * · 服务端明确拒绝时抹掉本地凭据的 last_ok(Code 保留)→ 码恢复正常能自动复活。 * · CacheFresh 加两项:「配置里的码 ≠ 本地凭据的码 → 旧凭据作废」+ 防系统时间回拨 * (当前时间比上次成功早 6h 以上 = 有人在拨表 → 不认宽限,强制联网复核)。 * · 新增 giftcenter.deauth 注销命令(清本地凭据 + 清 config 里的码,立刻回未授权)。 * --------------------------------------------------------------------------- */ namespace Oxide.Plugins { [Info("福利中心 GiftCenter", "薯片", "1.0.5")] [Description("GiftCenter: four-tab welfare panel (new-server welcome / 7-day sign-in / online-time rewards / support contact) with custom Rust-style art via ImageLibrary")] public class GiftCenter : RustPlugin { [PluginReference] private Plugin ImageLibrary; #region Constants private const string PanelName = "giftcenter.panel"; private const string PermVip = "giftcenter.vip"; private const string PermAdmin = "giftcenter.admin"; private const int CycleDays = 7; private const int TabCount = 4; // 面板基准尺寸(实际 = 基准 × config 面板缩放 PanelScale) private const float BaseW = 1000f; private const float BaseH = 620f; // ---- 左菜单 ---- private const float MenuX0 = 14f, MenuX1 = 186f; private const float MenuY0 = 60f, MenuY1 = 600f; // y0=60:给左下角饰(64px)让位 private const float MenuBtnX0 = 25f, MenuBtnX1 = 175f; // 宽 150(底板图 300x163 → 1:1 缩 2 倍) private const float MenuBtnW = 150f, MenuBtnH = 82f, MenuBtnGap = 10f; private const float MenuBtnTop = 508f; // 第 1 个按钮的顶边(往下排) // ---- 右区 ---- private const float ColX0 = 200f, ColX1 = 966f; // 宽 766 private const float BannerY0 = 490f, BannerY1 = 606f; // 高 116 private const float ContentY0 = 152f, ContentY1 = 478f; // 高 326 private const float ActionY0 = 92f, ActionY1 = 140f; // 高 48 private const float FootY0 = 56f, FootY1 = 84f; // ---- 签到卡片 ---- private const float CardY0 = 176f, CardY1 = 402f; // 高 226 private const float CardGap = 10f; private const float CardPad = 14f; // 内容区左右内边距 // ---- 素材显示尺寸(全部 1:1,不许拉伸)---- private const float CornerSize = 64f; private const float BannerArtW = 320f, BannerArtH = 116f; // ---- 插件授权(v1.0.2 新增)---- private const string AuthApiBase = "http://lic.biubr.com"; // 授权服务地址,换服只改这一处 private const string AuthPluginId = "giftcenter"; // 必须与后台插件清单的 plugin_id 一致 private const string AuthCacheFile = "GiftCenter.auth"; // -> oxide/data/GiftCenter.auth.json private const float AuthRetrySeconds = 300f; // 未授权时每 5 分钟自动重试 private const float AuthRenewHours = 6f; // ★ v1.0.5:常驻复核间隔,每 6h 主动问一次服务器 private const float AuthNudgeSeconds = 600f; // 未授权时往控制台写提醒的间隔 #endregion #region Fields private PluginConfig _config; private Dictionary _playerData = new Dictionary(); private Dictionary _openTab = new Dictionary(); private readonly HashSet _popupShown = new HashSet(); private int _registerAttempts; // ---- 授权运行时状态(v1.0.2)---- 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("素材图 Assets")] public AssetsData Assets = new AssetsData(); [JsonProperty("新服登录 NewServer")] public NewServerData NewServer = new NewServerData(); [JsonProperty("签到好礼 SignIn")] public SignInData SignIn = new SignInData(); [JsonProperty("在线礼包 OnlineRewards")] public OnlineData Online = new OnlineData(); [JsonProperty("联系客服 Support")] public SupportData Support = new SupportData(); [JsonProperty("授权 Authorize")] public AuthData Authorize = new AuthData(); } // v1.0.2 插件授权配置。授权码留空 = 未授权(面板只显示激活说明页)。 private class AuthData { [JsonProperty("插件授权码 LicenseCode")] public string LicenseCode = ""; [JsonProperty("离线宽限天数 OfflineGraceDays")] public int OfflineGraceDays = 3; // ★ v1.0.5:7 -> 3,只在「连不上服务器」时才启用 [JsonProperty("启动时检查新版本 CheckUpdate")] public bool CheckUpdate = true; } private class SettingsData { [JsonProperty("面板标题 PanelTitle")] public string PanelTitle = "福利中心"; [JsonProperty("面板缩放 PanelScale (0.7 - 1.4,1 = 1000x620)")] public float PanelScale = 1f; [JsonProperty("进服自动弹面板 PopupOnConnect")] public bool PopupOnConnect = true; [JsonProperty("弹面板延迟秒数 PopupDelaySeconds")] public float PopupDelaySeconds = 5f; [JsonProperty("VIP奖励倍数 VipMultiplier")] public float VipMultiplier = 2f; [JsonProperty("在线时长统计间隔秒 TickSeconds (10-300)")] public float TickSeconds = 60f; [JsonProperty("在线时长写盘间隔秒 SaveIntervalSeconds (30-1800)")] public float SaveIntervalSeconds = 120f; [JsonProperty("领取时全服广播 BroadcastOnClaim")] public bool BroadcastOnClaim = true; [JsonProperty("广播内容 BroadcastMessage ({player}/{item}/{amount})")] public string BroadcastMessage = "{player} 在福利中心领到了 {item} ×{amount}!"; [JsonProperty("底部提示 FooterText")] public string FooterText = "聊天框输入 /gift 可随时打开福利中心"; } // 素材表:只填文件名,真 URL = 图床前缀 + / + 文件名 // 全留空 = 纯色玻璃风(什么图都不加载,功能完全一样) private class AssetsData { // v1.0.3:说明字段 —— 程序不读它,纯粹让服主打开 config 一眼看到素材图该放哪 [JsonProperty("★素材图放这里(说明·程序不读) AssetsFolder")] public string AssetsFolderHint = "oxide/data/GiftCenter/assets/"; // v1.0.3:默认留空 = 用 assets 文件夹里的内嵌素材。 // ★ 老默认值是作者自己的图床域名,买家装上去会去拉作者的图床(拉不到就纯色玻璃风)。 // 要用图床的服主自己填 https:// 地址;填了就不再看 assets 文件夹。 [JsonProperty("图床前缀 ImageBaseUrl (留空 = 用 assets 文件夹里的 PNG)")] public string ImageBaseUrl = ""; [JsonProperty("面板背景 Background")] public string Background = "bg-camp.png"; [JsonProperty("菜单底板未选中 MenuNormal")] public string MenuNormal = "ui-menu-normal.png"; [JsonProperty("菜单底板选中 MenuActive")] public string MenuActive = "ui-menu-active.png"; [JsonProperty("四角装饰左上 CornerTL")] public string CornerTL = "ui-corner-tl.png"; [JsonProperty("四角装饰右上 CornerTR")] public string CornerTR = "ui-corner-tr.png"; [JsonProperty("四角装饰左下 CornerBL")] public string CornerBL = "ui-corner-bl.png"; [JsonProperty("四角装饰右下 CornerBR")] public string CornerBR = "ui-corner-br.png"; [JsonProperty("横幅新服登录 BannerNewServer")] public string BannerNewServer = "banner-newserver.png"; [JsonProperty("横幅签到好礼 BannerSignIn")] public string BannerSignIn = "banner-signin.png"; [JsonProperty("横幅在线礼包 BannerOnline")] public string BannerOnline = "banner-online.png"; [JsonProperty("横幅联系客服 BannerSupport")] public string BannerSupport = "banner-support.png"; [JsonProperty("立绘新服登录 HeroNewServer")] public string HeroNewServer = "hero-survivor.png"; [JsonProperty("插画在线礼包 ArtOnline")] public string ArtOnline = "page-online.png"; [JsonProperty("二维码联系客服 QrSupport")] public string QrSupport = "qr-placeholder.png"; [JsonProperty("图标VIP IconVip")] public string IconVip = "ui-icon-vip.png"; [JsonProperty("图标锁定 IconLock")] public string IconLock = "ui-icon-lock.png"; [JsonProperty("图标已领 IconStamp")] public string IconStamp = "ui-icon-stamp.png"; [JsonProperty("图标沙漏 IconHourglass")] public string IconHourglass = "ui-icon-hourglass.png"; [JsonProperty("图标客服 IconSupport")] public string IconSupport = "ui-icon-support.png"; [JsonProperty("图标公告 IconNotice")] public string IconNotice = "ui-icon-notice.png"; [JsonProperty("图标礼盒 IconGift")] public string IconGift = "ui-icon-gift.png"; [JsonProperty("图标物资箱 IconCrate")] public string IconCrate = "ui-icon-crate.png"; [JsonProperty("图标齿轮 IconGear")] public string IconGear = "ui-icon-gear.png"; [JsonProperty("单图覆盖 Overrides (键=文件名,值=完整URL)")] public Dictionary Overrides = new Dictionary(); } private class NewServerData { [JsonProperty("启用 Enabled")] public bool Enabled = true; [JsonProperty("页签标题 TabTitle")] public string TabTitle = "新服登录"; [JsonProperty("页签副标题 TabSubtitle")] public string TabSubtitle = "欢迎来到废土,先领一份新手礼包"; [JsonProperty("开服日期 ServerOpenDate (yyyy-MM-dd,留空 = 不显示开服天数)")] public string ServerOpenDate = ""; [JsonProperty("正文 Text (\\n 换行,最多显示 8 行)")] public string Text = "欢迎来到本服!\n\n· 新人保护期 3 天,期间被拆家请联系客服\n· 常用命令 /kit 领新手装备、/tpr 随机传送\n· 严禁外挂、卡 BUG,查到直接封禁\n· 有问题点左边「联系客服」找我们"; [JsonProperty("新手礼包一人一次 NewbiePackOnce")] public bool NewbiePackOnce = true; [JsonProperty("新手礼包限开服N天内 NewbiePackWithinDays (0 = 不限)")] public int NewbiePackWithinDays = 7; [JsonProperty("新手礼包物品 NewbiePack")] public List NewbiePack = new List(); [JsonProperty("领取时全服广播 BroadcastOnClaim")] public bool BroadcastOnClaim = true; [JsonProperty("播报内容 BroadcastMessage ({player})")] public string BroadcastMessage = "{player} 加入了废土,领走了新手礼包!"; } private class SignInData { [JsonProperty("启用 Enabled")] public bool Enabled = true; [JsonProperty("页签标题 TabTitle")] public string TabTitle = "签到好礼"; [JsonProperty("页签副标题 TabSubtitle")] public string TabSubtitle = "连签 7 天 · 天天有奖 · VIP 双倍"; [JsonProperty("7 天奖励 Rewards")] public List Rewards = new List(); [JsonProperty("领取时全服广播 BroadcastOnClaim")] public bool BroadcastOnClaim = true; [JsonProperty("播报内容 BroadcastMessage ({player}/{day}/{item}/{amount})")] public string BroadcastMessage = "{player} 完成第 {day} 天签到,领到 {item} ×{amount}!"; } private class OnlineData { [JsonProperty("启用 Enabled")] public bool Enabled = true; [JsonProperty("页签标题 TabTitle")] public string TabTitle = "在线礼包"; [JsonProperty("页签副标题 TabSubtitle")] public string TabSubtitle = "在线时长越长,奖励越厚"; [JsonProperty("档位每日重置 ResetDaily")] public bool ResetDaily = true; [JsonProperty("累计总时长 CountTotal")] public bool CountTotal = true; [JsonProperty("档位 Tiers (最多显示 4 档)")] public List Tiers = new List(); [JsonProperty("领取时全服广播 BroadcastOnClaim")] public bool BroadcastOnClaim = true; [JsonProperty("播报内容 BroadcastMessage ({player}/{minutes}/{item}/{amount})")] public string BroadcastMessage = "{player} 在线满 {minutes} 分钟,领到 {item} ×{amount}!"; } private class SupportData { [JsonProperty("启用 Enabled")] public bool Enabled = true; [JsonProperty("页签标题 TabTitle")] public string TabTitle = "联系客服"; [JsonProperty("页签副标题 TabSubtitle")] public string TabSubtitle = "有问题找我们,别自己憋着"; [JsonProperty("客服QQ QQ")] public string QQ = ""; [JsonProperty("客服微信 WeChat")] public string WeChat = ""; [JsonProperty("客服QQ群 QQGroup")] public string QQGroup = ""; [JsonProperty("在线时间 ServiceHours")] public string ServiceHours = "每天 10:00 - 24:00"; [JsonProperty("客服说明 Note")] public string Note = "· 充值 / 报销 / 申诉 都走客服,别在公屏发\n· 举报外挂请带上录像或截图,方便核实\n· 客服不会向你要账号密码,谨防冒充"; [JsonProperty("二维码图片覆盖 QrImageUrl (留空 = 用素材表里的占位图)")] public string QrImageUrl = ""; [JsonProperty("二维码说明 QrCaption")] public string QrCaption = "扫码加客服(用你真码替换素材表里的 qr 图)"; [JsonProperty("显示赞助入口 ShowDonate")] public bool ShowDonate = true; [JsonProperty("赞助说明 DonateNote")] public string DonateNote = "赞助全部用于服务器续费与带宽,名单在群里公示"; } private class PackItem { [JsonProperty("物品 Shortname")] public string Shortname = "wood"; [JsonProperty("数量 Amount")] public int Amount = 1000; [JsonProperty("皮肤ID SkinID (0 = 原版)")] public ulong SkinID = 0UL; [JsonProperty("显示名 DisplayName")] public string DisplayName = "木头"; [JsonProperty("图标文件 IconImage (留空 = 自动取游戏物品图标)")] public string IconImage = ""; } 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("显示名 DisplayName")] public string DisplayName = "木头"; [JsonProperty("图标文件 IconImage (留空 = 自动取游戏物品图标)")] public string IconImage = ""; [JsonProperty("图标URL IconUrl (留空 = 用 IconImage / 游戏图标)")] public string IconUrl = ""; } private class TierItem { [JsonProperty("档位序号 Tier (1起)")] public int Tier = 1; [JsonProperty("需要在线分钟 Minutes")] public int Minutes = 30; [JsonProperty("物品 Shortname")] public string Shortname = "scrap"; [JsonProperty("数量 Amount")] public int Amount = 50; [JsonProperty("皮肤ID SkinID (0 = 原版)")] public ulong SkinID = 0UL; [JsonProperty("显示名 DisplayName")] public string DisplayName = "废金属"; [JsonProperty("图标文件 IconImage (留空 = 自动取游戏物品图标)")] public string IconImage = ""; [JsonProperty("图标URL IconUrl (留空 = 用 IconImage / 游戏图标)")] public string IconUrl = ""; } private class PlayerData { [JsonProperty("Streak")] public int Streak; [JsonProperty("TotalClaims")] public int TotalClaims; [JsonProperty("LastClaimDate")] public string LastClaimDate = ""; [JsonProperty("NewbieClaimed")] public bool NewbieClaimed; [JsonProperty("FirstSeenDate")] public string FirstSeenDate = ""; [JsonProperty("OnlineSecondsToday")] public double OnlineSecondsToday; [JsonProperty("OnlineDate")] public string OnlineDate = ""; [JsonProperty("TotalOnlineSeconds")] public double TotalOnlineSeconds; [JsonProperty("ClaimedTiers")] public List ClaimedTiers = new List(); [JsonProperty("MigratedFrom")] public string MigratedFrom = ""; } private PluginConfig BuildDefaultConfig() { var cfg = new PluginConfig(); cfg.NewServer.NewbiePack = new List { new PackItem { Shortname = "wood", Amount = 2000, DisplayName = "木头", IconImage = "icon-01-wood.png" }, new PackItem { Shortname = "stones", Amount = 2000, DisplayName = "石头", IconImage = "icon-02-stones.png" }, new PackItem { Shortname = "metal.ore", Amount = 1000, DisplayName = "金属矿石", IconImage = "icon-03-metal-ore.png" }, new PackItem { Shortname = "cloth", Amount = 500, DisplayName = "布", IconImage = "icon-05-cloth.png" }, new PackItem { Shortname = "scrap", Amount = 200, DisplayName = "废金属", IconImage = "icon-06-scrap.png" }, new PackItem { Shortname = "box.wooden", Amount = 1, DisplayName = "木制储物箱", IconImage = "icon-07-box-wooden.png" } }; cfg.SignIn.Rewards = new List { new RewardItem { Day = 1, Shortname = "wood", Amount = 500, DisplayName = "木头", IconImage = "icon-01-wood.png" }, new RewardItem { Day = 2, Shortname = "stones", Amount = 1000, DisplayName = "石头", IconImage = "icon-02-stones.png" }, new RewardItem { Day = 3, Shortname = "metal.ore", Amount = 500, DisplayName = "金属矿石", IconImage = "icon-03-metal-ore.png" }, new RewardItem { Day = 4, Shortname = "sulfur.ore", Amount = 300, DisplayName = "硫磺矿石", IconImage = "icon-04-sulfur-ore.png" }, new RewardItem { Day = 5, Shortname = "cloth", Amount = 500, DisplayName = "布", IconImage = "icon-05-cloth.png" }, new RewardItem { Day = 6, Shortname = "scrap", Amount = 300, DisplayName = "废金属", IconImage = "icon-06-scrap.png" }, new RewardItem { Day = 7, Shortname = "box.wooden.large", Amount = 1, DisplayName = "大型物资箱", IconImage = "icon-07-box-wooden.png" } }; cfg.Online.Tiers = new List { new TierItem { Tier = 1, Minutes = 30, Shortname = "scrap", Amount = 50, DisplayName = "废金属", IconImage = "icon-06-scrap.png" }, new TierItem { Tier = 2, Minutes = 60, Shortname = "cloth", Amount = 200, DisplayName = "布", IconImage = "icon-05-cloth.png" }, new TierItem { Tier = 3, Minutes = 120, Shortname = "metal.fragments", Amount = 500, DisplayName = "金属碎片", IconImage = "icon-03-metal-ore.png" }, new TierItem { Tier = 4, Minutes = 240, Shortname = "box.wooden", Amount = 1, DisplayName = "木制储物箱", IconImage = "icon-07-box-wooden.png" } }; return cfg; } protected override void LoadDefaultConfig() { PrintWarning("已生成默认配置(四个页签的内容都在 oxide/config/GiftCenter.json 里改)"); _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.Assets == null) _config.Assets = new AssetsData(); if (_config.Assets.Overrides == null) _config.Assets.Overrides = new Dictionary(); if (_config.NewServer == null) _config.NewServer = new NewServerData(); if (_config.SignIn == null) _config.SignIn = new SignInData(); if (_config.Online == null) _config.Online = new OnlineData(); if (_config.Support == null) _config.Support = new SupportData(); if (_config.Authorize == null) _config.Authorize = new AuthData(); // v1.0.2:老配置没有这一段 if (_config.NewServer.NewbiePack == null || _config.NewServer.NewbiePack.Count == 0) throw new Exception("no newbie pack"); if (_config.SignIn.Rewards == null || _config.SignIn.Rewards.Count == 0) throw new Exception("no sign-in rewards"); if (_config.Online.Tiers == null || _config.Online.Tiers.Count == 0) throw new Exception("no online tiers"); } catch (Exception ex) { PrintWarning($"配置文件无效({ex.Message}),已重建默认配置"); _config = BuildDefaultConfig(); } SaveConfig(); } protected override void SaveConfig() => Config.WriteObject(_config); #endregion #region Lifecycle private void OnServerInitialized() { permission.RegisterPermission(PermVip, this); permission.RegisterPermission(PermAdmin, this); LoadData(); MigrateFromLoginRewards(); EnsureAssetFolder(true); // v1.0.3:先把素材文件夹建好 + 写说明书 + 报缺图 ValidateConfig(); RegisterImages(); StartOnlineTimer(); // v1.0.2:授权判定(软锁)。没授权时下面所有功能入口都不会放行。 InitAuthorize(); // 只提醒不拦:落后于后台登记版本就在控制台提示 timer.Once(10f, CheckPluginVersion); } private void Unload() { _unloaded = true; // 异步回调(联网校验 / 版本查询)到此直接丢弃 foreach (var p in BasePlayer.activePlayerList) CuiHelper.DestroyUi(p, PanelName); SaveData(); } private void OnPluginLoaded(Plugin plugin) { if (plugin != null && plugin.Name == "ImageLibrary") RegisterImages(); } private void ValidateConfig() { foreach (var r in _config.SignIn.Rewards) if (string.IsNullOrEmpty(r.Shortname) || ItemManager.FindItemDefinition(r.Shortname) == null) PrintWarning($"配置警告:签到第 {r.Day} 天 shortname '{r.Shortname}' 无效,玩家将领不到"); foreach (var t in _config.Online.Tiers) if (string.IsNullOrEmpty(t.Shortname) || ItemManager.FindItemDefinition(t.Shortname) == null) PrintWarning($"配置警告:在线礼包第 {t.Tier} 档 shortname '{t.Shortname}' 无效,玩家将领不到"); foreach (var it in _config.NewServer.NewbiePack) if (string.IsNullOrEmpty(it.Shortname) || ItemManager.FindItemDefinition(it.Shortname) == null) PrintWarning($"配置警告:新手礼包 shortname '{it.Shortname}' 无效,玩家将领不到"); var imgUrl = (_config.Assets.ImageBaseUrl ?? string.Empty).Trim(); if (imgUrl.Length == 0) { if (UseEmbedded()) PrintWarning($"素材图未配图床 → 使用内嵌素材({AllAssetFiles().Count()} 张,不需要外网与证书)"); else PrintWarning("素材图没配(assets 文件夹里也没有 PNG)→ 面板使用纯色玻璃风,功能不受影响。" + "想上精修素材图:把随包的 PNG 拷进 " + AssetDirPath() + " 后执行 oxide.reload GiftCenter"); } else if (imgUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) { PrintWarning($"素材图 ImageBaseUrl '{imgUrl}' 是 HTTP —— 图片库会把 http:// 无条件改写成 " + "https://,纯 HTTP 图床必然一张都拉不到。请改成 https:// 地址"); } else if (!imgUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { PrintWarning($"素材图 ImageBaseUrl '{imgUrl}' 不是 https:// 开头,图片库会按 https 去拉,可能全部失败"); } else if (imgUrl.IndexOf("sprust.cn", StringComparison.OrdinalIgnoreCase) >= 0) { PrintWarning("素材图 ImageBaseUrl 用的是 sprust.cn —— 请确认首尔已给该域名配好 SSL 证书(443) 并反向代理到" + "图床服务,同时把 33 张 PNG 放在 C:\\licapi\\ 根目录(放子目录会被拦)"); } ProbeImageHost(); if (!string.IsNullOrEmpty(_config.NewServer.ServerOpenDate)) { DateTime tmp; if (!DateTime.TryParse(_config.NewServer.ServerOpenDate, out tmp)) PrintWarning($"配置警告:开服日期 '{_config.NewServer.ServerOpenDate}' 格式不对,应为 yyyy-MM-dd"); } } // 图床连通性自检:启动时打一条日志,让服主一眼知道图到底能不能上屏。 // 面板本身不依赖它 —— 自检失败也只是降级成纯色玻璃风,不影响任何功能。 private void ProbeImageHost() { if (!UseImages()) return; var url = UrlOf(_config.Assets.Background); if (string.IsNullOrEmpty(url)) return; // 故意做与 ImageLibrary 完全相同的改写,这样自检结果 = 玩家实际看到的结果 var probe = url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ? url.Replace("http://", "https://") : url; try { webrequest.Enqueue(probe, null, (code, response) => { if (code == 200) PrintWarning($"图床自检通过:{probe} 可下载(HTTP 200),已注册 {AllAssetFiles().Count()} 张素材图"); else if (code == 404) PrintWarning($"图床自检:{probe} 返回 404 —— 服务通了但文件不在。请把 33 张 PNG 放到首尔 " + "C:\\licapi\\ 根目录(子目录会被拦),文件名别改"); else PrintWarning($"图床自检失败(HTTP {code}):{probe} —— 界面会降级为纯色玻璃风。常见原因:" + "① 图床是 http 而不是 https ② 443 / 证书没配好 ③ 反向代理没指向图床端口"); }, this, RequestMethod.GET, null, 15f); } catch (Exception ex) { PrintWarning($"图床自检发起失败:{ex.Message}"); } } #endregion #region Data private const string DataFileName = "GiftCenter/players"; private void LoadData() { try { _playerData = Interface.Oxide.DataFileSystem.ReadObject>(DataFileName) ?? new Dictionary(); } catch (Exception) { PrintWarning("数据文件读取失败,使用空数据"); _playerData = new Dictionary(); } foreach (var kv in _playerData) { if (kv.Value == null) { _playerData[kv.Key] = new PlayerData(); continue; } if (kv.Value.ClaimedTiers == null) kv.Value.ClaimedTiers = new List(); } } private void SaveData() { try { Interface.Oxide.DataFileSystem.WriteObject(DataFileName, _playerData); } catch (Exception ex) { PrintWarning($"数据写盘失败: {ex.Message}"); } } private PlayerData EnsureData(ulong userId) { PlayerData d; if (!_playerData.TryGetValue(userId, out d) || d == null) { d = new PlayerData { FirstSeenDate = NowDateString() }; _playerData[userId] = d; } return d; } private static string NowDateString() => DateTime.Now.ToString("yyyy-MM-dd"); // 数据迁移:把 LoginRewards v1.x 的签到进度搬过来,老玩家不断签 private void MigrateFromLoginRewards() { bool anyLocal = _playerData.Values.Any(v => v != null && !string.IsNullOrEmpty(v.LastClaimDate)); if (anyLocal) return; // 本地已有签到数据 → 说明迁过了,不重复搬 Dictionary legacy; try { legacy = Interface.Oxide.DataFileSystem.ReadObject>("LoginRewards"); } catch (Exception) { legacy = null; } if (legacy == null || legacy.Count == 0) return; int n = 0; foreach (var kv in legacy) { if (kv.Value == null) continue; var d = EnsureData(kv.Key); d.Streak = kv.Value.Streak; d.TotalClaims = kv.Value.TotalClaims; d.LastClaimDate = kv.Value.LastClaimDate ?? ""; d.MigratedFrom = "LoginRewards"; n++; } SaveData(); PrintWarning($"已从 LoginRewards 迁移 {n} 名玩家的签到进度到 GiftCenter/players.json(不会断签)"); } private class LegacyData { [JsonProperty("Streak")] public int Streak; [JsonProperty("TotalClaims")] public int TotalClaims; [JsonProperty("LastClaimDate")] public string LastClaimDate = ""; } #endregion #region Images (ImageLibrary) // 内嵌素材位置:oxide/data/GiftCenter/assets/<素材文件名>.png(直接放 PNG 二进制) // 不走 JSON / base64:体积最小,也没有任何序列化风险;换某一张图覆盖同名文件即可 private const string EmbeddedDir = "GiftCenter"; private const string EmbeddedSub = "assets"; private bool? _embedOk; // v1.0.3:素材文件夹路径缓存 + 缺图清单(控制台 giftcenter.assets 与启动日志共用) private string _assetDir; private List _lastMissing = new List(); private static string EmbedName(string fileName) { var name = fileName ?? string.Empty; int slash = name.LastIndexOf('/'); if (slash >= 0) name = name.Substring(slash + 1); int dot = name.LastIndexOf('.'); if (dot > 0) name = name.Substring(0, dot); return name; } private static string EmbedFullPath(string fileName) { var name = EmbedName(fileName); if (string.IsNullOrEmpty(name)) return null; return Path.Combine(Interface.Oxide.DataDirectory, EmbeddedDir, EmbeddedSub, name + ".png"); } private bool UseImages() { return _config != null && _config.Assets != null && !string.IsNullOrEmpty(_config.Assets.ImageBaseUrl); } // 图床优先;没填图床但有内嵌素材就走内嵌 private bool UseEmbedded() { if (_config == null || _config.Assets == null) return false; if (UseImages()) return false; if (_embedOk == null) { try { var p = EmbedFullPath(_config.Assets.Background); _embedOk = !string.IsNullOrEmpty(p) && File.Exists(p); } catch { _embedOk = false; } } return _embedOk.Value; } private string UrlOf(string fileName) { if (!UseImages() || string.IsNullOrEmpty(fileName)) return null; string over; if (_config.Assets.Overrides != null && _config.Assets.Overrides.TryGetValue(fileName, out over) && !string.IsNullOrEmpty(over)) return over; return _config.Assets.ImageBaseUrl.TrimEnd('/') + "/" + fileName.TrimStart('/'); } // 文件名 → ImageLibrary 键(一眼看出属于本插件) private static string KeyOf(string fileName) { if (string.IsNullOrEmpty(fileName)) return null; var name = fileName; int slash = name.LastIndexOf('/'); if (slash >= 0) name = name.Substring(slash + 1); int dot = name.LastIndexOf('.'); if (dot > 0) name = name.Substring(0, dot); return "giftcenter." + name; } private IEnumerable AllAssetFiles() { var a = _config.Assets; var list = new List { a.Background, a.MenuNormal, a.MenuActive, a.CornerTL, a.CornerTR, a.CornerBL, a.CornerBR, a.BannerNewServer, a.BannerSignIn, a.BannerOnline, a.BannerSupport, a.HeroNewServer, a.ArtOnline, a.QrSupport, a.IconVip, a.IconLock, a.IconStamp, a.IconHourglass, a.IconSupport, a.IconNotice, a.IconGift, a.IconCrate, a.IconGear }; foreach (var r in _config.SignIn.Rewards) if (!string.IsNullOrEmpty(r.IconImage)) list.Add(r.IconImage); foreach (var t in _config.Online.Tiers) if (!string.IsNullOrEmpty(t.IconImage)) list.Add(t.IconImage); foreach (var it in _config.NewServer.NewbiePack) if (!string.IsNullOrEmpty(it.IconImage)) list.Add(it.IconImage); return list.Where(x => !string.IsNullOrEmpty(x)).Distinct(); } private void RegisterImages() { if (ImageLibrary == null || !Convert.ToBoolean(ImageLibrary.Call("IsReady"))) { if (_registerAttempts++ < 30) timer.Once(5f, RegisterImages); return; } if (!UseImages() && !UseEmbedded()) return; // ① 预热游戏内置物品图标(ImageLibrary 自带全物品图标表,用 shortname 直接要) foreach (var r in _config.SignIn.Rewards) WarmItemIcon(r.Shortname); foreach (var t in _config.Online.Tiers) WarmItemIcon(t.Shortname); foreach (var it in _config.NewServer.NewbiePack) WarmItemIcon(it.Shortname); if (UseImages()) { // ② 图床路线 // ★ AddImage 签名 = (string url, string imageName, ulong imageId),url 在前! // 写成 (key, url, 0) 的话键会变成 "https://..._0",GetImage 永远查不到(而且不报错) foreach (var file in AllAssetFiles()) { var url = UrlOf(file); var key = KeyOf(file); if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(key)) continue; try { ImageLibrary.Call("AddImage", url, key, 0UL); } catch (Exception ex) { PrintWarning($"注册素材图失败 {file}: {ex.Message}"); } } return; } // ③ 内嵌路线:素材字节直接喂给图片库 // ★ AddImageData 签名 = (string imageName, byte[] array, ulong imageId) // 这条路走 StoreByteArray():不碰 http/https、不需要证书、没有 3 MB 上限 int okN = 0, miss = 0; var missingNames = new List(); // v1.0.3:缺图清单,直接告诉服主缺哪些 foreach (var file in AllAssetFiles()) { var key = KeyOf(file); if (string.IsNullOrEmpty(key)) continue; try { var path = EmbedFullPath(file); if (string.IsNullOrEmpty(path) || !File.Exists(path)) { miss++; missingNames.Add(file); continue; } var bytes = File.ReadAllBytes(path); if (bytes == null || bytes.Length < 8) { miss++; continue; } ImageLibrary.Call("AddImageData", key, bytes, 0UL); okN++; } catch (Exception ex) { miss++; PrintWarning($"内嵌素材读取失败 {file}: {ex.Message}"); } } Puts($"内嵌素材已注册 {okN} 张" + (miss > 0 ? $"(缺 {miss} 张,缺的那几张会退回纯色文字)" : "")); if (miss > 0) { Puts(" 素材图文件夹:" + (_assetDir ?? AssetDirPath())); Puts(" 还缺:" + string.Join("、", missingNames.Distinct().Take(12).ToArray()) + (missingNames.Distinct().Count() > 12 ? " 等" : "") + " → 补齐后执行 oxide.reload GiftCenter"); } } // ================= v1.0.3:素材文件夹自建 + 自查(买家/服主一看就懂) ================= // 装完插件启动一次,oxide/data/GiftCenter/assets/ 就自动出现,里面还带一份 // 《★素材图放这里.txt》—— 不用问作者、不用猜、不用去翻手册。 private string AssetDirPath() { return Path.Combine(Interface.Oxide.DataDirectory, EmbeddedDir, EmbeddedSub); } // withLog=true 时顺便统计"已有几张 / 缺哪几张"并打日志(启动时调); // false 时只保证文件夹与说明文件存在(控制台命令调)。 private void EnsureAssetFolder(bool withLog) { try { var dir = AssetDirPath(); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); _assetDir = dir; // 说明文件只在缺失时写,绝不覆盖服主自己改过的 var readme = Path.Combine(dir, "★素材图放这里.txt"); if (!File.Exists(readme)) File.WriteAllText(readme, AssetReadmeText(), new UTF8Encoding(true)); _lastMissing = new List(); int have = 0; foreach (var f in AllAssetFiles()) { var p = EmbedFullPath(f); if (!string.IsNullOrEmpty(p) && File.Exists(p)) have++; else _lastMissing.Add(f); } if (!withLog) return; int want = AllAssetFiles().Count(); if (have == 0) { Puts("============================================================"); Puts("【福利中心】素材图文件夹已经帮你建好了,但里面还一张图都没有:"); Puts(" " + dir); Puts(" 怎么放:把买插件时随包的「素材图」文件夹里的 PNG 全部拷进上面这个文件夹,"); Puts(" 文件名一个字都别改,也别再建子文件夹。"); Puts(" 放好后:控制台执行 oxide.reload GiftCenter"); Puts(" (或用「Rust 服务器管理工具 → ③ 插件库 → 福利中心 → 重载」)"); Puts(" ★ 现在面板会先用「纯色玻璃风」——功能完全正常,只是没那么好看。"); Puts(" ★ 具体放哪些文件:看该文件夹里的《★素材图放这里.txt》,里面一张一张列全了。"); Puts("============================================================"); } else if (have < want) { Puts($"【福利中心】素材图 {have}/{want} 张就位,还缺 {_lastMissing.Count} 张(缺的那几张会退回纯色)。"); Puts(" 文件夹:" + dir); Puts(" 还缺:" + string.Join("、", _lastMissing.Take(10).ToArray()) + (_lastMissing.Count > 10 ? " 等" : "") + " → 补齐后执行 oxide.reload GiftCenter"); } } catch (Exception ex) { PrintWarning("创建/检查素材图文件夹失败:" + ex.Message); } } // 自动生成的说明书(写进 assets 文件夹里,UTF-8 带 BOM,记事本打开不乱码) private string AssetReadmeText() { var L = new List(); L.Add("============================================================"); L.Add(" 福利中心 GiftCenter —— 素材图要放哪里?"); L.Add(" (这份说明是插件第一次启动时自动生成的,看完可以删)"); L.Add("============================================================"); L.Add(""); L.Add(" 你现在打开的【就是】插件读图的地方,不用再找别处:"); L.Add(""); L.Add(" " + AssetDirPath()); L.Add(""); L.Add("------------------------------------------------------------"); L.Add(" 一、要放什么?"); L.Add("------------------------------------------------------------"); L.Add(" 把买插件时一起拿到的「素材图」文件夹里的 PNG,"); L.Add(" 【全部】复制到本文件夹里(一共 33 张,其中 30 张是界面在用的)。"); L.Add(""); L.Add(" ★ 文件名一个字都不要改!也不要再建子文件夹!"); L.Add(" 本文件夹里的 PNG 文件名 = config 里「素材图 Assets」配的名字。"); L.Add(""); L.Add("------------------------------------------------------------"); L.Add(" 二、放好之后要做什么?"); L.Add("------------------------------------------------------------"); L.Add(" 回到游戏服控制台执行:"); L.Add(""); L.Add(" oxide.reload GiftCenter"); L.Add(""); L.Add(" 或点「Rust 服务器管理工具 → ③ 插件库 → 福利中心 → 重载」。"); L.Add(" 重启服务器同样会生效。"); L.Add(""); L.Add("------------------------------------------------------------"); L.Add(" 三、一张都不放会怎样?"); L.Add("------------------------------------------------------------"); L.Add(" 功能完全不受影响,只是界面从「精修素材图」变成「纯色玻璃风」。"); L.Add(""); L.Add("------------------------------------------------------------"); L.Add(" 四、这 30 张分别是什么(照抄文件名,别写错)"); L.Add("------------------------------------------------------------"); L.Add(" 【1】面板骨架 7 张"); L.Add(" bg-camp.png 面板背景(整张底图 1000x620)"); L.Add(" ui-menu-normal.png 左侧菜单底板 · 未选中"); L.Add(" ui-menu-active.png 左侧菜单底板 · 选中"); L.Add(" ui-corner-tl.png 左上角装饰 64x64"); L.Add(" ui-corner-tr.png 右上角装饰 64x64"); L.Add(" ui-corner-bl.png 左下角装饰 64x64"); L.Add(" ui-corner-br.png 右下角装饰 64x64"); L.Add(""); L.Add(" 【2】四个页签的横幅 4 张(320x116)"); L.Add(" banner-newserver.png 横幅 · 新服登录"); L.Add(" banner-signin.png 横幅 · 签到好礼"); L.Add(" banner-online.png 横幅 · 在线礼包"); L.Add(" banner-support.png 横幅 · 联系客服"); L.Add(""); L.Add(" 【3】大图 / 插画 / 二维码 3 张"); L.Add(" hero-survivor.png 新服登录页立绘(200x316)"); L.Add(" page-online.png 在线礼包页插画(340x296)"); L.Add(" qr-placeholder.png ★二维码位(240x240)—— 换成你自己的收款/客服码,"); L.Add(" 就做一张同名同尺寸的图覆盖它即可"); L.Add(""); L.Add(" 【4】界面小图标 9 张(96x96)"); L.Add(" ui-icon-vip.png VIP 标记"); L.Add(" ui-icon-lock.png 未解锁锁头"); L.Add(" ui-icon-stamp.png 已领取印章"); L.Add(" ui-icon-hourglass.png 在线计时沙漏"); L.Add(" ui-icon-support.png 客服"); L.Add(" ui-icon-notice.png 公告"); L.Add(" ui-icon-gift.png 礼包"); L.Add(" ui-icon-crate.png 物资箱"); L.Add(" ui-icon-gear.png 设置齿轮"); L.Add(""); L.Add(" 【5】奖励物品图标 7 张(96x96)—— 改 config 里的奖励时记得同名放图"); L.Add(" icon-01-wood.png 木头"); L.Add(" icon-02-stones.png 石头"); L.Add(" icon-03-metal-ore.png 金属矿石"); L.Add(" icon-04-sulfur-ore.png 硫磺矿石"); L.Add(" icon-05-cloth.png 布"); L.Add(" icon-06-scrap.png 废金属"); L.Add(" icon-07-box-wooden.png 箱子"); L.Add(""); L.Add(" ---------------------------- 以下 3 张先留着,当前版本没用到 ----------------------------"); L.Add(" icon-08-gift-box.png / page-newserver.png / page-support.png"); L.Add(" (以后版本扩展新页签会用到,放着不占事,也可以先不放)"); L.Add(""); L.Add("------------------------------------------------------------"); L.Add(" 五、想换成自己的图?"); L.Add("------------------------------------------------------------"); L.Add(" 直接用同名文件覆盖就行。尺寸尽量按上面标的来 ——"); L.Add(" Rust 的界面没有九宫格拉伸,插件是按 1:1 画的,图尺寸不对就会变形。"); L.Add(" ★ 图必须是 PNG(JPG 会裂图)。"); L.Add(""); L.Add(" 另一种做法是走图床:config 里把「图床前缀 ImageBaseUrl」填成 https 地址,"); L.Add(" 图从图床取,本文件夹就不生效了。两种方式二选一,别混着用:"); L.Add(" ImageBaseUrl 留空 → 用本文件夹里的 PNG(推荐,离线可用)"); L.Add(" ImageBaseUrl 填了 → 用图床(必须 https,http 会被图片库改写成 https 拿不到)"); L.Add(""); L.Add("------------------------------------------------------------"); L.Add(" 六、怎么确认到底生效没有?"); L.Add("------------------------------------------------------------"); L.Add(" 控制台执行:"); L.Add(""); L.Add(" giftcenter.assets"); L.Add(""); L.Add(" 会告诉你:文件夹在哪、已有几张、还缺哪几张。"); L.Add(" 启动日志里也会打「内嵌素材已注册 N 张」。"); L.Add(""); L.Add("============================================================"); L.Add(" 作者:薯片 插件:福利中心 GiftCenter"); L.Add("============================================================"); return string.Join(Environment.NewLine, L.ToArray()); } private void WarmItemIcon(string shortname) { if (string.IsNullOrEmpty(shortname)) return; try { ImageLibrary.Call("GetImage", shortname, 0UL); } catch (Exception ex) { PrintWarning($"预热物品图标失败 {shortname}: {ex.Message}"); } } // ★ GetImage 签名 = (string imageName, ulong imageId = 0, bool returnUrl = false) // 第三个参数是 bool,别把 userID 传进去(LoginRewards 旧版就踩过这个坑) // "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")) return null; return sprite; } catch { return null; } } // 三级取图:直连外链 → 素材文件 → 游戏内置物品图标 private string SpriteForReward(string iconUrl, string iconImage, string shortname) { if (!string.IsNullOrEmpty(iconUrl)) return iconUrl; if (!string.IsNullOrEmpty(iconImage)) { var s = GetSprite(KeyOf(iconImage)); if (!string.IsNullOrEmpty(s)) return s; } if (!string.IsNullOrEmpty(shortname)) return GetSprite(shortname); return null; } private string AssetSprite(string fileName) => string.IsNullOrEmpty(fileName) ? null : GetSprite(KeyOf(fileName)); #endregion // ---- 未授权时的激活说明页(软锁:不报错、不卸载,只讲怎么激活)---- private void ShowAuthPanel(BasePlayer player) { if (player == null || !player.IsConnected) return; CuiHelper.DestroyUi(player, PanelName); float s = Scale(); const float AW = 760f; const float AH = 420f; float W = AW * s, H = AH * s; var c = new CuiElementContainer(); var root = new CuiPanel(); root.Image.Color = "0.05 0.06 0.08 0.98"; root.RectTransform.AnchorMin = "0.5 0.5"; root.RectTransform.AnchorMax = "0.5 0.5"; root.RectTransform.OffsetMin = $"{-W / 2f} {-H / 2f}"; root.RectTransform.OffsetMax = $"{W / 2f} {H / 2f}"; root.CursorEnabled = true; c.Add(root, "Overlay", PanelName); c.Add(MkLabel("福 利 中 心 · 尚 未 授 权", 40, 344, 720, 384, 18, TextAnchor.MiddleLeft, "0.95 0.74 0.42 1", s), PanelName); c.Add(MkLabel("当前状态:" + _authHint, 40, 314, 720, 340, 12, TextAnchor.MiddleLeft, "0.74 0.80 0.86 1", s), PanelName); string[] lines = { "第 1 步 把服务器的公网 IP 发给作者,拿到属于你的授权码(形如 PL-XXXX-XXXX-XXXX)", "第 2 步 服务器控制台 / RCON / F1 执行: giftcenter.auth PL-XXXX-XXXX-XXXX", "第 3 步 看到「授权成功」后执行 oxide.reload GiftCenter ,四个页签立刻全部生效", "也可以把码填进配置文件「授权 Authorize → 插件授权码 LicenseCode」再重载插件。", "", "一个授权码可以绑 2 台服务器(主服 + 测试服);要换服请联系作者解绑旧的那台。", "授权成功后本机会记一份凭据,3 天内即使断网、或授权服务临时不通,插件照常工作。" }; float y = 276f; foreach (var ln in lines) { if (!string.IsNullOrEmpty(ln)) c.Add(MkLabel(ln, 46, y - 22, 726, y, 12, TextAnchor.MiddleLeft, "0.84 0.88 0.93 1", s), PanelName); y -= 30f; } c.Add(MkButton("giftcenter.close", "关闭", 310, 26, 450, 66, 14, "0.30 0.13 0.13 0.95", "0.95 0.88 0.88 1", s), PanelName); c.Add(MkLabel("买了码用不了?加作者微信 shupian1315,把服务器 IP 和授权码发过来", 26, 6, 734, 26, 11, TextAnchor.MiddleCenter, "0.55 0.60 0.66 1", s), PanelName); CuiHelper.AddUi(player, c); } // ===================================================================== #region 授权(插件授权码 · 联网校验 · 离线宽限) // ===================================================================== // 【软锁】策略(与《_插件产品化规范与售卖流程.md》§2.4 一致): // · 未授权时插件【照常加载、不报错、不卸载】,命令照常注册 —— // 这样服主能直接用 giftcenter.auth <码> 自救,不会卡在"卸载了就没法激活"的死循环。 // · 只在「功能入口」拦一道(EnsureAuthorized),未授权时只给管理员看激活说明页, // 普通玩家静默拒绝(不刷屏、不报红字,玩家看不出服务器有问题)。 // · 联网校验走 webrequest 异步回调,绝不阻塞服务器主线程。 // · 离线宽限 3 天:作者服务器挂了 / 买家断网,不能把买家的服搞停。 // · 一个授权码能绑几台服务器由授权服务(plugcodes.max_slots)说了算, // 插件侧只负责把服务端拒绝的原因(含「已经绑满 N 台」)原样翻成中文告诉服主。 // ===================================================================== // ---- 本地授权缓存:oxide/data/GiftCenter.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.0.5 起默认值改成 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; // ★ v1.0.5:配置里写了另一个码 -> 旧凭据立刻作废(换了码不许旧凭据继续生效) 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; // ★ v1.0.5:防系统时间回拨 —— 当前时间比上次成功校验还早 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.0.5:常驻复核,撤销最长 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("或在服务器控制台执行:giftcenter.auth <你的授权码>,然后 oxide.reload GiftCenter。"); PrintAuthStatus(); return; // 没码就不必空转重试,等服主填码后重载 } _authHint = "正在联网校验…"; RequestVerify(null, null, false); } private void RetryAuth() { if (_unloaded || _authorized) return; if (string.IsNullOrEmpty(CurrentCode())) return; RequestVerify(null, null, false); } // ★ v1.0.5:常驻复核。已授权状态也必须定期问服务器一次 —— // 否则作者在后台「停用」或删掉授权码之后,插件靠本地宽限能一直跑到过期, // 等于撤销不了(v1.0.4 及以前:只在 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 + ")。" + "激活:控制台执行 giftcenter.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, "还没有授权码。用法:giftcenter.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; } // 情形三:真没授权 → 功能不生效 // ★ v1.0.5:服务端【明确拒绝】时抹掉本地凭据的 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,且买的是本插件;" + "重新校验:控制台执行 giftcenter.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("===== 福利中心 GiftCenter 授权状态 ====="); Puts(" 状态:" + (_authorized ? "已授权(功能正常)" : "未授权(功能不生效)")); Puts(" 授权码:" + MaskCode(CurrentCode())); Puts(" 插件版本:" + Version.ToString() + (string.IsNullOrEmpty(_latestVersion) ? "" : ("(后台最新 " + _latestVersion + ")"))); Puts(" 本地凭据:" + (CacheFresh() ? ("有效,至 " + GraceExpireText()) : "无 / 已过期")); Puts(" 主动复核:每 " + AuthRenewHours + " 小时一次;仅「连不上服务器」时才用 " + GraceDays + " 天宽限"); Puts(" 用法:giftcenter.auth (不带参数 = 只看状态)"); Puts(" giftcenter.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) return; var d = EnsureData(player.userID); if (string.IsNullOrEmpty(d.FirstSeenDate)) d.FirstSeenDate = NowDateString(); if (!_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) return; _popupShown.Remove(player.userID); _openTab.Remove(player.userID); AccumulateOnline(player); // 断线立刻结算,别丢时长 SaveData(); } #endregion #region Online time // 每 TickSeconds 给在线玩家加时长;不用 OnPlayerTick(每帧都调,白烧性能) private void StartOnlineTimer() { float t = Mathf.Clamp(_config.Settings.TickSeconds, 10f, 300f); timer.Repeat(t, 0, () => { foreach (var p in BasePlayer.activePlayerList) AccumulateOnline(p); }); float sv = Mathf.Clamp(_config.Settings.SaveIntervalSeconds, 30f, 1800f); timer.Repeat(sv, 0, SaveData); } private void AccumulateOnline(BasePlayer player) { if (player == null || !player.IsConnected) return; var d = EnsureData(player.userID); var today = NowDateString(); if (d.OnlineDate != today) { d.OnlineDate = today; d.OnlineSecondsToday = 0; if (_config.Online.ResetDaily) d.ClaimedTiers.Clear(); } float step = Mathf.Clamp(_config.Settings.TickSeconds, 10f, 300f); d.OnlineSecondsToday += step; if (_config.Online.CountTotal) d.TotalOnlineSeconds += step; } private int TodayMinutes(PlayerData d, string today) { if (d == null) return 0; if (d.OnlineDate != today) return 0; return (int)Math.Floor(d.OnlineSecondsToday / 60.0); } #endregion #region Commands [ChatCommand("gift")] private void CmdGift(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())} (用法:/gift 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; } if (args != null && args.Length > 0) { int t; if (int.TryParse(args[0], out t)) { _openTab[player.userID] = Mathf.Clamp(t, 0, TabCount - 1); } else { switch (args[0].ToLower()) { case "new": case "newserver": _openTab[player.userID] = 0; break; case "sign": case "signin": _openTab[player.userID] = 1; break; case "online": _openTab[player.userID] = 2; break; case "sup": case "support": _openTab[player.userID] = 3; break; } } } ShowPanel(player); } [ChatCommand("giftadmin")] private void CmdGiftAdmin(BasePlayer player, string command, string[] args) { if (!IsAdmin(player)) { SendReply(player, "无权限:需要 giftcenter.admin"); return; } if (args == null || args.Length < 1) { SendReply(player, "用法:/giftadmin view <玩家> | reset <玩家> | resetall | signin <玩家> | online <玩家>"); return; } string sub = args[0].ToLower(); if (sub == "resetall") { _playerData.Clear(); SaveData(); SendReply(player, "已清空全部玩家的福利中心数据(签到 / 在线 / 新手礼包 全重置)"); return; } if (sub != "view" && sub != "reset" && sub != "signin" && sub != "online") { SendReply(player, "未知子命令,可用:view / reset / resetall / signin / online"); return; } if (args.Length < 2) { SendReply(player, "这条命令要带玩家名或 SteamID64"); return; } var id = ResolveTargetId(args[1]); if (id == 0UL) { SendReply(player, $"找不到玩家:{args[1]}(在线玩家可用名字,离线请用 SteamID64)"); return; } PlayerData d; if (!_playerData.TryGetValue(id, out d) || d == null) d = new PlayerData(); if (sub == "reset") { _playerData.Remove(id); SendReply(player, $"已重置玩家 {id} 的全部福利中心数据"); } else if (sub == "view") { SendReply(player, $"玩家 {id}:连签 {d.Streak} 天 | 累计签到 {d.TotalClaims} 次 | 最后签到 {(string.IsNullOrEmpty(d.LastClaimDate) ? "-" : d.LastClaimDate)}"); SendReply(player, $"今日在线 {TodayMinutes(d, NowDateString())} 分钟 | 累计在线 {(int)(d.TotalOnlineSeconds / 3600)} 小时 | 已领档位 {d.ClaimedTiers.Count} 个 | 新手礼包 {(d.NewbieClaimed ? "已领" : "未领")}"); } else if (sub == "signin") { d.LastClaimDate = ""; d.Streak = 0; _playerData[id] = d; SendReply(player, $"已清除玩家 {id} 的签到记录(今天可以重新签)"); } else { d.ClaimedTiers.Clear(); d.OnlineSecondsToday = 0; d.OnlineDate = NowDateString(); _playerData[id] = d; SendReply(player, $"已重置玩家 {id} 的今日在线时长与领取记录"); } SaveData(); } [ConsoleCommand("giftcenter.assets")] private void CcAssets(ConsoleSystem.Arg arg) { EnsureAssetFolder(false); int have = 0; var miss = new List(); foreach (var f in AllAssetFiles()) { var p = EmbedFullPath(f); if (!string.IsNullOrEmpty(p) && File.Exists(p)) have++; else miss.Add(f); } Puts("============================================================"); Puts("【福利中心】素材图状态"); Puts(" 文件夹:" + (_assetDir ?? AssetDirPath())); Puts(" 已就位:" + have + "/" + AllAssetFiles().Count() + " 张"); Puts(" 读取方式:" + (UseImages() ? "图床(config 填了 ImageBaseUrl," + _config.Assets.ImageBaseUrl + ",assets 文件夹不生效)" : "内嵌 assets 文件夹")); if (miss.Count > 0) { Puts(" 还缺 " + miss.Count + " 张:" + string.Join("、", miss.Take(12).ToArray()) + (miss.Count > 12 ? " 等" : "")); Puts(" → 把随包的 PNG 拷进上面那个文件夹,然后执行 oxide.reload GiftCenter"); } else { Puts(" 全部就位,界面用的是精修素材图。"); } Puts(" 说明书:该文件夹里的《★素材图放这里.txt》"); Puts("============================================================"); } [ConsoleCommand("giftcenter.tab")] private void CcTab(ConsoleSystem.Arg arg) { var player = arg == null ? null : arg.Player(); if (player == null || !player.IsConnected) return; _openTab[player.userID] = Mathf.Clamp(arg.GetInt(0, 1), 0, TabCount - 1); ShowPanel(player); } [ConsoleCommand("giftcenter.signin")] private void CcSignIn(ConsoleSystem.Arg arg) { var player = arg == null ? null : arg.Player(); if (player == null || !player.IsConnected) return; var err = ClaimSignIn(player); if (err != null) SendReply(player, err); _openTab[player.userID] = 1; ShowPanel(player); } [ConsoleCommand("giftcenter.newbie")] private void CcNewbie(ConsoleSystem.Arg arg) { var player = arg == null ? null : arg.Player(); if (player == null || !player.IsConnected) return; var err = ClaimNewbiePack(player); if (err != null) SendReply(player, err); _openTab[player.userID] = 0; ShowPanel(player); } [ConsoleCommand("giftcenter.online")] private void CcOnlineClaim(ConsoleSystem.Arg arg) { var player = arg == null ? null : arg.Player(); if (player == null || !player.IsConnected) return; var err = ClaimOnlineTiers(player); if (err != null) SendReply(player, err); _openTab[player.userID] = 2; ShowPanel(player); } [ConsoleCommand("giftcenter.close")] private void CcClose(ConsoleSystem.Arg arg) { var player = arg == null ? null : arg.Player(); if (player != null) CuiHelper.DestroyUi(player, PanelName); } // 控制台 / RCON 授权入口:giftcenter.auth [授权码],不带参数只看状态 [ConsoleCommand("giftcenter.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); } // ★ v1.0.5 注销入口:清掉本地凭据与配置里的授权码,立刻回到未授权状态。 // 用途:换机 / 退款 / 服主自查。以前自救只能去删 oxide/data/GiftCenter.auth.json。 [ConsoleCommand("giftcenter.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(" 重新激活:giftcenter.auth PL-XXXX-XXXX-XXXX"); if (player != null) Msg(player, "已注销,插件回到未授权状态。"); } private bool IsAdmin(BasePlayer player) { return player.IsAdmin || permission.UserHasPermission(player.UserIDString, PermAdmin); } 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 private bool IsVip(BasePlayer player) { return permission.UserHasPermission(player.UserIDString, PermVip); } private int WithVip(int baseAmount, bool vip) { if (!vip) return baseAmount; return Mathf.Max(1, Mathf.RoundToInt(baseAmount * _config.Settings.VipMultiplier)); } // 返回 null = 成功 private string ClaimSignIn(BasePlayer player) { // ★ 软锁:未授权一律不发奖励 if (!EnsureAuthorized(player)) return "本插件尚未授权,暂时不能领取签到,请联系服主。"; if (!_config.SignIn.Enabled) return "签到功能当前已关闭"; var d = EnsureData(player.userID); var today = NowDateString(); if (d.LastClaimDate == today) return "今天已经签过了,明天再来!"; var yesterday = DateTime.Now.AddDays(-1).ToString("yyyy-MM-dd"); d.Streak = d.LastClaimDate == yesterday ? d.Streak + 1 : 1; // 断签(或首次)从第 1 天重来 d.LastClaimDate = today; d.TotalClaims += 1; int index = (d.Streak - 1) % CycleDays; var reward = GetSignReward(index); if (reward == null) { SaveData(); return $"第 {index + 1} 天的奖励没配置,联系服主"; } bool vip = IsVip(player); int amount = WithVip(reward.Amount, vip); if (!GiveItem(player, reward.Shortname, amount, reward.SkinID, reward.DisplayName)) return "发放失败,联系服主看后台日志"; SaveData(); SendReply(player, $"签到成功(连续 {d.Streak} 天):{reward.DisplayName} ×{amount}" + (vip ? $" VIP ×{_config.Settings.VipMultiplier:0.#} 已生效" : "")); if (_config.Settings.BroadcastOnClaim && _config.SignIn.BroadcastOnClaim) Broad(_config.SignIn.BroadcastMessage, "{player}", player.displayName, "{day}", (index + 1).ToString(), "{amount}", amount.ToString(), "{item}", reward.DisplayName); return null; } private string ClaimNewbiePack(BasePlayer player) { // ★ 软锁:未授权一律不发奖励 if (!EnsureAuthorized(player)) return "本插件尚未授权,暂时不能领取新手礼包,请联系服主。"; if (!_config.NewServer.Enabled) return "这个页签当前已关闭"; var d = EnsureData(player.userID); if (_config.NewServer.NewbiePackOnce && d.NewbieClaimed) return "新手礼包只能领一次,你已经领过了"; if (_config.NewServer.NewbiePackWithinDays > 0 && !string.IsNullOrEmpty(_config.NewServer.ServerOpenDate)) { int days = DaysSinceOpen(); if (days > _config.NewServer.NewbiePackWithinDays) return $"新手礼包只在开服 {_config.NewServer.NewbiePackWithinDays} 天内发放(本服已开 {days} 天)"; } int given = 0; foreach (var it in _config.NewServer.NewbiePack) if (GiveItem(player, it.Shortname, it.Amount, it.SkinID, it.DisplayName)) given++; if (given == 0) return "发放失败,联系服主看后台日志"; d.NewbieClaimed = true; SaveData(); SendReply(player, $"新手礼包已发放({given} 件),背包满了会掉在脚下"); if (_config.Settings.BroadcastOnClaim && _config.NewServer.BroadcastOnClaim) Broad(_config.NewServer.BroadcastMessage, "{player}", player.displayName, "{amount}", given.ToString(), "{item}", "新手礼包"); return null; } private string ClaimOnlineTiers(BasePlayer player) { // ★ 软锁:未授权一律不发奖励 if (!EnsureAuthorized(player)) return "本插件尚未授权,暂时不能领取在线礼包,请联系服主。"; if (!_config.Online.Enabled) return "在线礼包当前已关闭"; var d = EnsureData(player.userID); var today = NowDateString(); if (d.OnlineDate != today) AccumulateOnline(player); int mins = TodayMinutes(d, today); bool vip = IsVip(player); var open = _config.Online.Tiers .Where(t => t.Minutes <= mins && !d.ClaimedTiers.Contains(t.Tier)) .OrderBy(t => t.Minutes).ToList(); if (open.Count == 0) { var nxt = _config.Online.Tiers.Where(t => t.Minutes > mins) .OrderBy(t => t.Minutes).FirstOrDefault(); if (nxt == null) return $"今天该领的都领完了(当前在线 {mins} 分钟)"; return $"还没有可领的档位:已在线 {mins} 分钟,下一档要 {nxt.Minutes} 分钟"; } int total = 0; foreach (var t in open) { int amount = WithVip(t.Amount, vip); if (!GiveItem(player, t.Shortname, amount, t.SkinID, t.DisplayName)) continue; d.ClaimedTiers.Add(t.Tier); total++; if (_config.Settings.BroadcastOnClaim && _config.Online.BroadcastOnClaim) Broad(_config.Online.BroadcastMessage, "{player}", player.displayName, "{minutes}", t.Minutes.ToString(), "{amount}", amount.ToString(), "{item}", t.DisplayName, "{tier}", t.Tier.ToString()); } if (total == 0) return "发放失败,联系服主看后台日志"; SaveData(); SendReply(player, $"已领取 {total} 个在线档位奖励" + (vip ? $"(VIP ×{_config.Settings.VipMultiplier:0.#} 已生效)" : "")); return null; } private bool GiveItem(BasePlayer player, string shortname, int amount, ulong skin, string displayName) { if (string.IsNullOrEmpty(shortname)) return false; if (ItemManager.FindItemDefinition(shortname) == null) { PrintWarning($"配置错误:shortname '{shortname}' 不存在,已跳过"); return false; } var item = ItemManager.CreateByName(shortname, Mathf.Max(1, amount), skin); if (item == null) { PrintWarning($"ItemManager.CreateByName({shortname}) 失败"); return false; } if (player.inventory.GiveItem(item)) return true; item.Drop(player.GetDropPosition(), player.GetDropVelocity()); SendReply(player, $"背包已满,{displayName} 掉在你脚下了,记得捡"); return true; } private void Broad(string template, params string[] pairs) { try { var msg = string.IsNullOrEmpty(template) ? "{player} 在福利中心领到了奖励!" : template; for (int i = 0; i + 1 < pairs.Length; i += 2) msg = msg.Replace(pairs[i], pairs[i + 1]); Server.Broadcast(msg); } catch (Exception ex) { PrintWarning($"广播失败: {ex.Message}"); } } private RewardItem GetSignReward(int index) { return _config.SignIn.Rewards.FirstOrDefault(x => x.Day == index + 1); } private int DaysSinceOpen() { return DaysSince(_config.NewServer.ServerOpenDate); } // 解析失败一律返回 -1(配置写错也只是不显示,不抛异常) private static int DaysSince(string dateStr) { if (string.IsNullOrEmpty(dateStr)) return -1; DateTime dt; if (!DateTime.TryParse(dateStr, out dt)) return -1; return (int)(DateTime.Now.Date - dt.Date).TotalDays; } #endregion #region GUI helpers 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 static CuiPanel MkPanel(float x0, float y0, float x1, float y1, string color, float s) { var p = new CuiPanel(); p.Image.Color = color; p.RectTransform.AnchorMin = "0 0"; p.RectTransform.AnchorMax = "0 0"; p.RectTransform.OffsetMin = P(x0, y0, s); p.RectTransform.OffsetMax = P(x1, y1, s); return p; } private static CuiLabel MkLabel(string text, float x0, float y0, float x1, float y1, int size, TextAnchor align, string color, float s) { var l = new CuiLabel(); l.Text.Text = text ?? ""; l.Text.FontSize = F(size, s); l.Text.Align = align; l.Text.Color = color; l.RectTransform.AnchorMin = "0 0"; l.RectTransform.AnchorMax = "0 0"; l.RectTransform.OffsetMin = P(x0, y0, s); l.RectTransform.OffsetMax = P(x1, y1, s); return l; } private static CuiButton MkButton(string command, string text, float x0, float y0, float x1, float y1, int size, string bg, string fg, float s) { var b = new CuiButton(); b.Button.Command = command; b.Button.Color = bg; b.Text.Text = text ?? ""; b.Text.FontSize = F(size, s); b.Text.Align = TextAnchor.MiddleCenter; b.Text.Color = fg; b.RectTransform.AnchorMin = "0 0"; b.RectTransform.AnchorMax = "0 0"; b.RectTransform.OffsetMin = P(x0, y0, s); b.RectTransform.OffsetMax = P(x1, y1, s); return b; } // ═══════════════════════════════════════════════════════════════════════ // ★★★ CUI 图片三条通路(真机踩坑记录,别再改回去) // // Oxide 的 CuiRawImageComponent / CuiImageComponent 各有三个"图片"字段, // 放进错的字段**不会报错**,客户端会把它当资源路径去 FileSystem 找, // 只在客户端 output_log.txt 里留一行: // [FileSystem] Not Found: 2781206807 (UnityEngine.Texture) // 画面上就是一块**白块**(RawImage 拿不到 texture 时用 color 铺满)。 // // ① Sprite —— Sprite 资源路径(游戏内置贴图),如 // "assets/content/ui/ui.background.tile.psd" // 走 CuiImageComponent,是**新版 Rust 唯一 100% 能出图**的通路 // ② Png —— FileStorage 图片 id(32 位 crc 的十进制字符串) // 走 CuiRawImageComponent.Png(json 键 "png") // ⚠️ 曾经错填在 Sprite 上 → 全部白块 // ③ Url —— 图片直链(http/https) // 走 CuiRawImageComponent.Url(json 键 "url") // ⚠️ 曾经错填在 Sprite 上 → 全部白块 // ④ ItemId —— 物品 ID,客户端直接用本地物品图标,不依赖存储与网络(最稳) // 走 CuiImageComponent.ItemId(json 键 "itemid") // // 另:CuiElement 的 Name 是客户端 GameObject 字典的键,**同容器内必须唯一**。 // 30 张图共用 "giftcenter.img" 会互相覆盖,也是白块成因之一。 // ═══════════════════════════════════════════════════════════════════════ // 游戏内置贴图(来自客户端 Bundles\textures\*.bundle 实测存在的资源路径) // 用途:自定义素材取不到时兜底,保证面板"有图"而不是一片白。大小写与 bundle 内一致,别改。 private const string SpBgTile = "assets/content/ui/ui.background.tile.psd"; private const string SpBgTileTx = "assets/content/ui/ui.background.tiletex.psd"; private const string SpWhite = "assets/content/ui/ui.white.tga"; private const string SpBoxSlice = "assets/content/ui/ui.box.sliced.png"; private const string SpBoxSharp = "assets/content/ui/ui.box.sharp.tga"; private const string SpGradDown = "assets/content/ui/ui.gradient.down.psd"; private const string SpGradUp = "assets/content/ui/ui.gradient.up.psd"; private const string SpLineDot = "assets/content/ui/ui.line.dotted.tga"; private const string SpRvButton = "assets/content/ui/roundedbutton.png"; private const string SpDarkBg = "assets/content/ui/facepunch-darkbg.png"; private const string SpCircGrad = "assets/content/ui/ui.circlegradient.png"; private const string SpRustIcon = "assets/content/ui/ui.icon.rust.png"; private const string SpTabOn = "assets/content/ui/developer/developmentskin/devtab-active.png"; private const string SpTabOff = "assets/content/ui/developer/developmentskin/devtab-normal.png"; // 图片元素名计数器:CUI 元素名必须唯一,30 张图共用一个名字会被客户端互相覆盖 private static int _imgSeq; private static bool IsBuiltinSprite(string s) => !string.IsNullOrEmpty(s) && s.StartsWith("assets/", StringComparison.OrdinalIgnoreCase); private static bool IsHttpUrl(string s) => !string.IsNullOrEmpty(s) && (s.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || s.StartsWith("https://", StringComparison.OrdinalIgnoreCase)); // 四条通路自动分流;itemId != 0 时优先用客户端本地物品图标 private static CuiElement MkImage(string sprite, float x0, float y0, float x1, float y1, string color, float s, float fade, int itemId = 0) { var rt = new CuiRectTransformComponent(); rt.AnchorMin = "0 0"; rt.AnchorMax = "0 0"; rt.OffsetMin = P(x0, y0, s); rt.OffsetMax = P(x1, y1, s); var e = new CuiElement { Name = "giftcenter.img." + (++_imgSeq) }; e.Components.Add(rt); if (itemId != 0) e.Components.Add(new CuiImageComponent { ItemId = itemId, Color = color, FadeIn = fade }); else if (IsBuiltinSprite(sprite)) e.Components.Add(new CuiImageComponent { Sprite = sprite, Color = color, FadeIn = fade }); else if (IsHttpUrl(sprite)) e.Components.Add(new CuiRawImageComponent { Url = sprite, Color = color, FadeIn = fade }); else e.Components.Add(new CuiRawImageComponent { Png = sprite, Color = color, FadeIn = fade }); return e; } // ★ 三处 CUI 父级写法(真机编译各报过一次,别再改回去): // 1) CuiPanel / CuiLabel / CuiButton 这些「组件」上**没有** Parent 属性 // → 走 c.Add(component, parent, name) // 2) 裸 CuiElement(自定义图片)不能走 c.Add(element, parent) // → 编译器会去找 Add(CuiButton, ...) 然后报 "cannot convert from 'CuiElement' to 'CuiButton'" // → 正确写法:先 element.Parent = xxx,再 c.Add(element) // fallback = 游戏内置贴图路径:自定义素材取不到时用它顶上(可选,不传就维持原行为) private void Img(CuiElementContainer c, string parent, string sprite, float x0, float y0, float x1, float y1, string color, float s, string fallback = null) { var use = sprite; if (string.IsNullOrEmpty(use)) use = fallback; if (string.IsNullOrEmpty(use)) return; var e = MkImage(use, x0, y0, x1, y1, color, s, 0.2f); e.Parent = parent; c.Add(e); } // 物品图标:直接给 itemid,客户端画本地图标 —— 不碰 FileStorage、不碰网络,必显示 private void ImgItem(CuiElementContainer c, string parent, int itemId, float x0, float y0, float x1, float y1, string color, float s) { if (itemId == 0) return; var e = MkImage(null, x0, y0, x1, y1, color, s, 0.2f, itemId); e.Parent = parent; c.Add(e); } // shortname -> 物品 ID(找不到返回 0) private static int ItemIdOf(string shortname) { if (string.IsNullOrEmpty(shortname)) return 0; try { var def = ItemManager.FindItemDefinition(shortname); return def == null ? 0 : def.itemid; } catch { return 0; } } // 压暗层:图再花也不影响读字 // ★ 组件的父级只能通过 container.Add(component, parent, name) 传; // CuiPanel / CuiLabel 这些 ICuiComponent 上没有 Parent 属性(真机编译报过) private void Veil(CuiElementContainer c, string parent, float x0, float y0, float x1, float y1, string color, float s, string name) { c.Add(MkPanel(x0, y0, x1, y1, color, s), parent, name); } #endregion #region Panel content private const string TabName0 = "新服登录"; private const string TabName1 = "签到好礼"; private const string TabName2 = "在线礼包"; private const string TabName3 = "联系客服"; private static string TabName(int tab) { switch (tab) { case 0: return TabName0; case 1: return TabName1; case 2: return TabName2; default: return TabName3; } } private string TabTitle(int tab) { switch (tab) { case 0: return _config.NewServer.TabTitle; case 1: return _config.SignIn.TabTitle; case 2: return _config.Online.TabTitle; default: return _config.Support.TabTitle; } } private string TabSubtitle(int tab) { switch (tab) { case 0: return _config.NewServer.TabSubtitle; case 1: return _config.SignIn.TabSubtitle; case 2: return _config.Online.TabSubtitle; default: return _config.Support.TabSubtitle; } } private string TabBanner(int tab) { var a = _config.Assets; switch (tab) { case 0: return a.BannerNewServer; case 1: return a.BannerSignIn; case 2: return a.BannerOnline; default: return a.BannerSupport; } } private string TabIcon(int tab) { var a = _config.Assets; switch (tab) { case 0: return a.IconNotice; case 1: return a.IconVip; case 2: return a.IconHourglass; default: return a.IconSupport; } } private bool TabEnabled(int tab) { switch (tab) { case 0: return _config.NewServer.Enabled; case 1: return _config.SignIn.Enabled; case 2: return _config.Online.Enabled; default: return _config.Support.Enabled; } } private void ShowPanel(BasePlayer player) { if (player == null || !player.IsConnected) return; // ★ 软锁:未授权时不给功能面板(管理员看到激活说明页,普通玩家静默拒绝) if (!EnsureAuthorized(player)) return; CuiHelper.DestroyUi(player, PanelName); var d = EnsureData(player.userID); bool vip = IsVip(player); int tab; if (!_openTab.TryGetValue(player.userID, out tab) || tab < 0 || tab >= TabCount) tab = d.NewbieClaimed ? 1 : 0; if (!TabEnabled(tab)) { int fallback = -1; for (int i = 0; i < TabCount; i++) if (TabEnabled(i)) { fallback = i; break; } tab = fallback >= 0 ? fallback : 1; } _openTab[player.userID] = tab; float s = Scale(); float W = BaseW * s, H = BaseH * s; var c = new CuiElementContainer(); // ---------- 主面板 ---------- var root = new CuiPanel(); root.Image.Color = "0.05 0.06 0.08 0.98"; root.RectTransform.AnchorMin = "0.5 0.5"; root.RectTransform.AnchorMax = "0.5 0.5"; root.RectTransform.OffsetMin = $"{-W / 2f} {-H / 2f}"; root.RectTransform.OffsetMax = $"{W / 2f} {H / 2f}"; root.CursorEnabled = true; c.Add(root, "Overlay", PanelName); // ---------- 背景图 + 压暗 ---------- Img(c, PanelName, AssetSprite(_config.Assets.Background), 0, 0, BaseW, BaseH, "1 1 1 1", s, SpBgTile); Veil(c, PanelName, 0, 0, BaseW, BaseH, "0.03 0.04 0.06 0.58", s, "giftcenter.veil"); // ---------- 四角装饰(64x64,1:1,四张独立图,左右/上下朝向各不同)---------- var a = _config.Assets; Img(c, PanelName, AssetSprite(a.CornerTL), 0, BaseH - CornerSize, CornerSize, BaseH, "1 1 1 0.95", s); Img(c, PanelName, AssetSprite(a.CornerTR), BaseW - CornerSize, BaseH - CornerSize, BaseW, BaseH, "1 1 1 0.95", s); Img(c, PanelName, AssetSprite(a.CornerBL), 0, 0, CornerSize, CornerSize, "1 1 1 0.95", s); Img(c, PanelName, AssetSprite(a.CornerBR), BaseW - CornerSize, 0, BaseW, CornerSize, "1 1 1 0.95", s); BuildLeftMenu(c, player, tab, d, s); BuildBanner(c, player, tab, vip, s); // 内容区底板 Veil(c, PanelName, ColX0, ContentY0, ColX1, ContentY1, "0.04 0.05 0.07 0.62", s, "giftcenter.content"); switch (tab) { case 0: BuildNewServer(c, player, d, s); break; case 1: BuildSignIn(c, player, d, vip, s); break; case 2: BuildOnline(c, player, d, vip, s); break; default: BuildSupport(c, player, d, s); break; } BuildActionRow(c, player, tab, d, s); // ---------- 底部 ---------- c.Add(MkLabel(_config.Settings.FooterText, ColX0, FootY0, 560, FootY1, 11, TextAnchor.MiddleLeft, "0.60 0.58 0.53 1", s), PanelName); int days = DaysSinceOpen(); c.Add(MkLabel($"GiftCenter v{Version} by 薯片 | 开服 {(days >= 0 ? days + " 天" : "未设置")} | 在线 {BasePlayer.activePlayerList.Count} 人", 580, FootY0, 900, FootY1, 11, TextAnchor.MiddleRight, "0.52 0.50 0.46 1", s), PanelName); CuiHelper.AddUi(player, c); } // ---------------- 左菜单(x 14~186 / y 14~600)---------------- private void BuildLeftMenu(CuiElementContainer c, BasePlayer player, int tab, PlayerData d, float s) { Veil(c, PanelName, MenuX0, MenuY0, MenuX1, MenuY1, "0.07 0.06 0.05 0.80", s, "giftcenter.menu"); c.Add(MkLabel("福 利 中 心", 30, 520, 184, 552, 14, TextAnchor.MiddleCenter, "0.85 0.72 0.47 1", s), PanelName); Veil(c, PanelName, 28, 512, 172, 514, "0.42 0.32 0.18 0.9", s, null); for (int i = 0; i < TabCount; i++) { float y1 = MenuBtnTop - i * (MenuBtnH + MenuBtnGap); float y0 = y1 - MenuBtnH; bool on = i == tab; bool en = TabEnabled(i); string plate = AssetSprite(on ? _config.Assets.MenuActive : _config.Assets.MenuNormal); if (!string.IsNullOrEmpty(plate)) Img(c, PanelName, plate, MenuBtnX0, y0, MenuBtnX1, y1, on ? "1 1 1 1" : "0.88 0.88 0.88 1", s); else Veil(c, PanelName, MenuBtnX0, y0, MenuBtnX1, y1, on ? "0.60 0.38 0.14 0.96" : "0.13 0.12 0.11 0.92", s, null); if (!en) Veil(c, PanelName, MenuBtnX0, y0, MenuBtnX1, y1, "0.02 0.02 0.03 0.55", s, null); string label = TabName(i); string color = !en ? "0.55 0.53 0.50 1" : on ? "1 0.92 0.74 1" : "0.84 0.82 0.78 1"; c.Add(MkLabel(label, MenuBtnX0, y0 + 26, MenuBtnX1, y0 + 58, 16, TextAnchor.MiddleCenter, color, s), PanelName); if (en) c.Add(MkButton($"giftcenter.tab {i}", "", MenuBtnX0, y0, MenuBtnX1, y1, 12, "0 0 0 0", "0 0 0 0", s), PanelName); } // 菜单底部状态摘要(3 行,最低边 68 > 左下角饰的 64,绝不压图) var today = NowDateString(); bool signedToday = d.LastClaimDate == today; int signDay = d.Streak <= 0 ? 1 : ((d.Streak - 1) % CycleDays) + 1; int mins = TodayMinutes(d, today); c.Add(MkLabel($"连签 {d.Streak} 天 · 累计 {d.TotalClaims} 次", 22, 112, 178, 134, 11, TextAnchor.MiddleCenter, "0.72 0.68 0.60 1", s), PanelName); c.Add(MkLabel(signedToday ? "今日签到 · 已完成" : $"今日可签 第 {signDay} 天", 22, 90, 178, 112, 11, TextAnchor.MiddleCenter, signedToday ? "0.46 0.80 0.50 1" : "0.93 0.71 0.34 1", s), PanelName); c.Add(MkLabel($"在线 {mins} 分钟 · 礼包{(d.NewbieClaimed ? "已领" : "待领")}", 22, 68, 178, 90, 11, TextAnchor.MiddleCenter, d.NewbieClaimed ? "0.60 0.58 0.55 1" : "0.88 0.66 0.32 1", s), PanelName); } // ---------------- 顶部横幅(x 200~966 / y 490~606)---------------- private void BuildBanner(CuiElementContainer c, BasePlayer player, int tab, bool vip, float s) { Veil(c, PanelName, ColX0, BannerY0, ColX1, BannerY1, "0.06 0.05 0.05 0.74", s, "giftcenter.banner"); // 横幅插画 320x116(1:1),贴右 Img(c, PanelName, AssetSprite(TabBanner(tab)), ColX1 - BannerArtW, BannerY0, ColX1, BannerY1, "1 1 1 0.92", s, SpGradDown); // 插画左侧渐隐接缝 Veil(c, PanelName, ColX1 - BannerArtW - 46, BannerY0, ColX1 - BannerArtW + 10, BannerY1, "0.06 0.05 0.05 0.62", s, null); // 页签小图标 48x48 Img(c, PanelName, AssetSprite(TabIcon(tab)), 220, 550, 268, 598, "1 1 1 0.92", s); // 标题 / 副标题(文字区 x 218~596,接缝渐隐从 600 起,两边不打架) c.Add(MkLabel(TabTitle(tab), 278, 556, 596, 598, 26, TextAnchor.MiddleLeft, "0.96 0.86 0.62 1", s), PanelName); c.Add(MkLabel(TabSubtitle(tab), 280, 528, 596, 552, 12, TextAnchor.MiddleLeft, "0.80 0.76 0.66 1", s), PanelName); Veil(c, PanelName, 280, 522, 540, 524, "0.85 0.66 0.31 0.85", s, null); // VIP 徽标 string badge = vip ? $"VIP ×{_config.Settings.VipMultiplier:0.#} 加成中" : "VIP 可享双倍奖励"; c.Add(MkPanel(282, 496, 462, 518, vip ? "0.72 0.55 0.16 0.95" : "0.16 0.17 0.20 0.92", s), PanelName); c.Add(MkLabel(badge, 282, 496, 462, 518, 11, TextAnchor.MiddleCenter, vip ? "0.12 0.09 0.02 1" : "0.76 0.79 0.83 1", s), PanelName); } // ---------------- 页签 ① 新服登录 ---------------- private void BuildNewServer(CuiElementContainer c, BasePlayer player, PlayerData d, float s) { const float Lx1 = 736f; // 左栏右边界(立绘从 766 开始) c.Add(MkLabel("服务器信息", ColX0 + 14, ContentY1 - 40, Lx1, ContentY1 - 10, 15, TextAnchor.MiddleLeft, "0.87 0.78 0.60 1", s), PanelName); int days = DaysSinceOpen(); int joined = DaysSince(d.FirstSeenDate); string info = days >= 0 ? $"本服已开服 {days} 天 | 当前在线 {BasePlayer.activePlayerList.Count} 人" + (joined >= 0 ? $" | 你已加入 {Mathf.Max(0, joined)} 天" : "") : $"当前在线 {BasePlayer.activePlayerList.Count} 人"; c.Add(MkLabel(info, ColX0 + 14, ContentY1 - 68, Lx1, ContentY1 - 42, 12, TextAnchor.MiddleLeft, "0.80 0.80 0.78 1", s), PanelName); // 正文(最多 8 行) var lines = (_config.NewServer.Text ?? "").Replace("\r\n", "\n").Split('\n'); float lineH = 20f; float yTop = ContentY1 - 82f; for (int i = 0; i < lines.Length && i < 8; i++) { float y1 = yTop - i * lineH; float y0 = y1 - lineH; if (y0 < 244f) break; // 给下面的礼包条留位 c.Add(MkLabel(lines[i], ColX0 + 14, y0, Lx1, y1, 12, TextAnchor.MiddleLeft, "0.78 0.79 0.80 1", s), PanelName); } // 新手礼包条(y 196~234) Veil(c, PanelName, ColX0 + 12, 196, Lx1, 234, "0.07 0.06 0.05 0.76", s, null); Img(c, PanelName, AssetSprite(_config.Assets.IconGift), 218, 199, 252, 233, "1 1 1 1", s); string packText; if (d.NewbieClaimed && _config.NewServer.NewbiePackOnce) packText = "新手礼包:已领取"; else { var names = _config.NewServer.NewbiePack.Take(5).Select(x => $"{x.DisplayName}×{x.Amount}").ToList(); packText = "新手礼包:" + string.Join(" ", names.ToArray()); } c.Add(MkLabel(packText, 260, 199, Lx1 - 6, 233, 12, TextAnchor.MiddleLeft, d.NewbieClaimed ? "0.60 0.62 0.60 1" : "0.90 0.84 0.68 1", s), PanelName); // 右侧立绘(源图 620x980 → 显示 200x316,比例 0.633 与 200/316 一致,零拉伸) Img(c, PanelName, AssetSprite(_config.Assets.HeroNewServer), 766, 158, 966, 474, "1 1 1 0.96", s, SpGradUp); } // ---------------- 页签 ② 签到好礼 ---------------- private void BuildSignIn(CuiElementContainer c, BasePlayer player, PlayerData d, bool vip, float s) { var today = NowDateString(); var yesterday = DateTime.Now.AddDays(-1).ToString("yyyy-MM-dd"); bool claimedToday = d.LastClaimDate == today; int nextStreak = claimedToday ? d.Streak : (d.LastClaimDate == yesterday ? d.Streak + 1 : 1); int todayIndex = claimedToday ? -1 : (nextStreak - 1) % CycleDays; int tomorrowIndex = claimedToday ? d.Streak % CycleDays : -1; int claimedUpTo = claimedToday ? (d.Streak - 1) % CycleDays : -1; float mult = vip ? _config.Settings.VipMultiplier : 1f; // 状态行(y 440~468) int infoIndex = todayIndex >= 0 ? todayIndex : tomorrowIndex; var infoReward = infoIndex >= 0 ? GetSignReward(infoIndex) : null; string head; if (claimedToday) head = $"今日已签到 | 明天可领第 {(d.Streak % CycleDays) + 1} 天"; else head = infoReward == null ? "奖励配置缺失,联系服主" : $"今天可领:第 {infoIndex + 1} 天 {infoReward.DisplayName} ×{Mathf.RoundToInt(infoReward.Amount * mult)}"; c.Add(MkLabel(head, ColX0 + 14, 440, 756, 468, 13, TextAnchor.MiddleLeft, claimedToday ? "0.62 0.64 0.62 1" : "0.96 0.76 0.38 1", s), PanelName); Img(c, PanelName, AssetSprite(_config.Assets.IconStamp), 766, 436, 802, 472, "1 1 1 0.9", s); c.Add(MkLabel($"连签 {d.Streak} 天 · 累计 {d.TotalClaims} 次", 808, 440, ColX1 - 14, 468, 12, TextAnchor.MiddleRight, "0.72 0.70 0.64 1", s), PanelName); // 7 张卡(内宽 738,卡宽 ≈96.86,高 226) float innerX0 = ColX0 + CardPad, innerX1 = ColX1 - CardPad; float cardW = (innerX1 - innerX0 - CardGap * (CycleDays - 1)) / CycleDays; for (int i = 0; i < CycleDays; i++) { var r = GetSignReward(i); float x = innerX0 + i * (cardW + CardGap); bool isClaimed = claimedToday && i <= claimedUpTo; bool isToday = i == todayIndex; bool isTomorrow = i == tomorrowIndex; bool isLast = i == CycleDays - 1; string cardBg = isToday ? "0.24 0.16 0.06 0.94" : isLast ? "0.18 0.15 0.09 0.94" : "0.11 0.12 0.15 0.94"; if (isClaimed) cardBg = "0.09 0.10 0.12 0.94"; if (isToday) Veil(c, PanelName, x - 1.5f, CardY0 - 1.5f, x + cardW + 1.5f, CardY1 + 1.5f, "0.88 0.58 0.18 0.95", s, null); Veil(c, PanelName, x, CardY0, x + cardW, CardY1, cardBg, s, null); // 天数(最上,y 378~398) // ★ 三元表达式在插值里必须加括号,否则 Oxide 的老 Roslyn 直接报 // "A conditional expression cannot be used directly in a string interpolation" string dayText = $"第 {(r != null ? r.Day : i + 1)} 天"; string dayColor = isLast ? "0.86 0.68 0.34 1" : "0.88 0.90 0.93 1"; if (isToday) { dayText = "今 日"; dayColor = "1 0.74 0.36 1"; } else if (isTomorrow) { dayText = "明 日"; dayColor = "0.52 0.76 1 1"; } else if (isClaimed) { dayText = "已领取"; dayColor = "0.58 0.86 0.62 1"; } c.Add(MkLabel(dayText, x, 378, x + cardW, 398, 13, TextAnchor.MiddleCenter, dayColor, s), PanelName); // 图标(y 270~366,96x96) // 顺序:外链 URL → 自定义素材(FileStorage) → 游戏本地物品图标(itemid) → 文字 string sprite = SpriteForReward(r == null ? null : r.IconUrl, r == null ? null : r.IconImage, null); int iid = r == null ? 0 : ItemIdOf(r.Shortname); float iconSize = 96f; float iconX = x + (cardW - iconSize) / 2f; string iconColor = isClaimed ? "0.50 0.50 0.50 0.55" : "1 1 1 1"; if (!string.IsNullOrEmpty(sprite)) Img(c, PanelName, sprite, iconX, 270, iconX + iconSize, 366, iconColor, s); else if (iid != 0) ImgItem(c, PanelName, iid, iconX, 270, iconX + iconSize, 366, iconColor, s); else c.Add(MkLabel(r == null ? "?" : r.DisplayName, x + 6, 292, x + cardW - 6, 344, 12, TextAnchor.MiddleCenter, "0.80 0.84 0.88 1", s), PanelName); // 物品名(y 234~262) c.Add(MkLabel(r == null ? "未配置" : r.DisplayName, x + 4, 234, x + cardW - 4, 262, 12, TextAnchor.MiddleCenter, isClaimed ? "0.52 0.55 0.58 1" : "0.84 0.87 0.90 1", s), PanelName); // 数量(y 196~228) string amt = r == null ? "未配置" : $"×{Mathf.RoundToInt(r.Amount * mult)}"; string amtColor = isToday ? "0.52 0.88 0.52 1" : isClaimed ? "0.42 0.44 0.46 1" : "0.86 0.89 0.92 1"; c.Add(MkLabel(amt, x + 4, 196, x + cardW - 4, 228, 14, TextAnchor.MiddleCenter, amtColor, s), PanelName); // 已领:整卡压暗;「已领取」直接写在最上面的天数栏里, // ★ 绝不再往图标上盖印章 —— LoginRewards 旧版就是这么糊成一团的 if (isClaimed) Veil(c, PanelName, x, CardY0, x + cardW, CardY1, "0.02 0.03 0.04 0.66", s, null); } } // ---------------- 页签 ③ 在线礼包 ---------------- private void BuildOnline(CuiElementContainer c, BasePlayer player, PlayerData d, bool vip, float s) { var today = NowDateString(); int mins = TodayMinutes(d, today); float mult = vip ? _config.Settings.VipMultiplier : 1f; var next = _config.Online.Tiers.Where(t => t.Minutes > mins) .OrderBy(t => t.Minutes).FirstOrDefault(); // 状态行:沙漏图标 + 一行汇总(12px,x 258~610 放得下约 32 个汉字) Img(c, PanelName, AssetSprite(_config.Assets.IconHourglass), 214, 434, 250, 470, "1 1 1 0.95", s); string stat = $"今日在线 {mins} 分钟 | " + (next == null ? "所有档位都已达成" : $"下一档 {next.Minutes} 分钟(还差 {next.Minutes - mins})"); if (_config.Online.CountTotal) stat += $" | 累计 {(int)(d.TotalOnlineSeconds / 3600)} 小时"; c.Add(MkLabel(stat, 258, 438, 610, 468, 12, TextAnchor.MiddleLeft, "0.88 0.80 0.62 1", s), PanelName); // 右侧插画 340x296 Img(c, PanelName, AssetSprite(_config.Assets.ArtOnline), 626, 168, 966, 464, "1 1 1 0.95", s, SpGradUp); // 4 个档位行(x 214~610,行高 58,间隔 6,从 y=420 往下排) const float Rx0 = 214f, Rx1 = 610f, RowH = 58f, RowGap = 6f, RowTop = 420f; var tiers = _config.Online.Tiers.OrderBy(t => t.Minutes).ToList(); for (int i = 0; i < tiers.Count && i < 4; i++) { var t = tiers[i]; float y1 = RowTop - i * (RowH + RowGap); float y0 = y1 - RowH; if (y0 < ContentY0 + 6) break; bool reached = mins >= t.Minutes; bool claimed = d.ClaimedTiers.Contains(t.Tier); string bg = claimed ? "0.09 0.11 0.10 0.92" : reached ? "0.18 0.14 0.06 0.94" : "0.10 0.11 0.13 0.92"; Veil(c, PanelName, Rx0, y0, Rx1, y1, bg, s, null); if (reached && !claimed) Veil(c, PanelName, Rx0, y0, Rx0 + 3, y1, "0.88 0.58 0.18 0.95", s, null); // 图标:外链 → 自定义素材 → 游戏本地物品图标 → 箱子贴图兜底 string sprite = SpriteForReward(t.IconUrl, t.IconImage, null); string iconC = claimed ? "0.50 0.50 0.50 0.55" : "1 1 1 1"; int iid = ItemIdOf(t.Shortname); if (!string.IsNullOrEmpty(sprite)) Img(c, PanelName, sprite, Rx0 + 6, y0 + 6, Rx0 + 52, y1 - 6, iconC, s); else if (iid != 0) ImgItem(c, PanelName, iid, Rx0 + 6, y0 + 6, Rx0 + 52, y1 - 6, iconC, s); else Img(c, PanelName, AssetSprite(_config.Assets.IconCrate), Rx0 + 6, y0 + 6, Rx0 + 52, y1 - 6, iconC, s, SpBoxSlice); // 名称 + 数量 / 时长 c.Add(MkLabel($"{t.DisplayName} ×{Mathf.RoundToInt(t.Amount * mult)}", Rx0 + 60, y0 + 30, Rx0 + 196, y1 - 4, 13, TextAnchor.MiddleLeft, claimed ? "0.55 0.57 0.55 1" : "0.90 0.88 0.82 1", s), PanelName); c.Add(MkLabel($"{t.Minutes} 分钟", Rx0 + 60, y0 + 6, Rx0 + 196, y0 + 28, 11, TextAnchor.MiddleLeft, "0.66 0.64 0.58 1", s), PanelName); // 进度条 + 状态(右区 x 416~550) float tx0 = 416f, tx1 = 550f; float prog = Mathf.Clamp01(mins / (float)Mathf.Max(1, t.Minutes)); Veil(c, PanelName, tx0, y0 + 36, tx1, y0 + 44, "0.16 0.17 0.19 0.9", s, null); if (prog > 0.01f) Veil(c, PanelName, tx0, y0 + 36, tx0 + (tx1 - tx0) * prog, y0 + 44, claimed ? "0.30 0.55 0.36 0.95" : "0.85 0.58 0.20 0.95", s, null); c.Add(MkLabel(claimed ? "已领取" : reached ? "可领取" : $"{(int)(prog * 100)}%", tx0, y0 + 10, tx1, y0 + 34, 11, TextAnchor.MiddleCenter, claimed ? "0.55 0.80 0.58 1" : reached ? "0.96 0.78 0.40 1" : "0.66 0.64 0.58 1", s), PanelName); // 锁定 / 已领图标(x 556~602) if (!reached) Img(c, PanelName, AssetSprite(_config.Assets.IconLock), 556, y0 + 8, 602, y0 + 50, "0.85 0.85 0.85 0.85", s); else if (claimed) Img(c, PanelName, AssetSprite(_config.Assets.IconStamp), 556, y0 + 8, 602, y0 + 50, "0.85 1 0.85 0.9", s); } } // ---------------- 页签 ④ 联系客服 ---------------- private void BuildSupport(CuiElementContainer c, BasePlayer player, PlayerData d, float s) { const float Lx1 = 596f; c.Add(MkLabel("联系方式", ColX0 + 14, 438, Lx1, 468, 15, TextAnchor.MiddleLeft, "0.96 0.84 0.56 1", s), PanelName); float y = 424f; var rows = new List(); if (!string.IsNullOrEmpty(_config.Support.QQ)) rows.Add($"客服 QQ:{_config.Support.QQ}"); if (!string.IsNullOrEmpty(_config.Support.WeChat)) rows.Add($"客服微信:{_config.Support.WeChat}"); if (!string.IsNullOrEmpty(_config.Support.QQGroup)) rows.Add($"玩家 QQ 群:{_config.Support.QQGroup}"); if (!string.IsNullOrEmpty(_config.Support.ServiceHours)) rows.Add($"在线时间:{_config.Support.ServiceHours}"); foreach (var line in rows) { Veil(c, PanelName, ColX0 + 12, y - 26, Lx1, y, "0.07 0.07 0.08 0.80", s, null); c.Add(MkLabel(line, ColX0 + 22, y - 26, Lx1 - 8, y, 13, TextAnchor.MiddleLeft, "0.90 0.88 0.82 1", s), PanelName); y -= 32f; } if (!string.IsNullOrEmpty(_config.Support.Note)) { var noteLines = _config.Support.Note.Replace("\r\n", "\n").Split('\n'); y -= 6f; for (int i = 0; i < noteLines.Length && i < 5; i++) { if (y - 20 < 216f) break; c.Add(MkLabel(noteLines[i], ColX0 + 16, y - 20, Lx1 - 6, y, 12, TextAnchor.MiddleLeft, "0.72 0.72 0.70 1", s), PanelName); y -= 21f; } } // 赞助条(y 160~206) if (_config.Support.ShowDonate && !string.IsNullOrEmpty(_config.Support.DonateNote)) { Veil(c, PanelName, ColX0 + 12, 160, Lx1, 206, "0.16 0.12 0.06 0.86", s, null); Img(c, PanelName, AssetSprite(_config.Assets.IconGift), 218, 164, 252, 202, "1 1 1 1", s); c.Add(MkLabel(_config.Support.DonateNote, 262, 164, Lx1 - 8, 202, 11, TextAnchor.MiddleLeft, "0.92 0.82 0.62 1", s), PanelName); } // 右侧二维码 240x240(x 700~940 / y 200~440) string qr = !string.IsNullOrEmpty(_config.Support.QrImageUrl) ? _config.Support.QrImageUrl : AssetSprite(_config.Assets.QrSupport); Veil(c, PanelName, 694, 194, 946, 446, "0.94 0.92 0.88 0.95", s, null); // 二维码:外链优先;内置兜底用白底贴图(扫不出来也不影响布局) Img(c, PanelName, qr, 700, 200, 940, 440, "1 1 1 1", s, SpWhite); c.Add(MkLabel(_config.Support.QrCaption, 700, 166, 950, 190, 12, TextAnchor.MiddleCenter, "0.78 0.76 0.70 1", s), PanelName); Img(c, PanelName, AssetSprite(_config.Assets.IconSupport), 650, 164, 694, 208, "1 1 1 0.9", s); } // ---------------- 按钮行(y 92~140)---------------- private void BuildActionRow(CuiElementContainer c, BasePlayer player, int tab, PlayerData d, float s) { float bx = ColX0, by0 = ActionY0, by1 = ActionY1; if (tab == 1) { bool claimedToday = d.LastClaimDate == NowDateString(); c.Add(MkButton("giftcenter.signin", claimedToday ? "今日已签到" : "领取今日奖励", bx, by0, bx + 200, by1, 15, claimedToday ? "0.18 0.20 0.22 0.95" : "0.62 0.40 0.12 0.97", claimedToday ? "0.60 0.62 0.64 1" : "1 0.96 0.86 1", s), PanelName); bx += 212; } else if (tab == 0) { bool done = d.NewbieClaimed && _config.NewServer.NewbiePackOnce; c.Add(MkButton("giftcenter.newbie", done ? "新手礼包已领取" : "领取新手礼包", bx, by0, bx + 210, by1, 15, done ? "0.18 0.20 0.22 0.95" : "0.24 0.44 0.28 0.97", done ? "0.60 0.62 0.64 1" : "0.94 1 0.94 1", s), PanelName); bx += 222; } else if (tab == 2) { int mins = TodayMinutes(d, NowDateString()); int openCount = _config.Online.Tiers.Count(t => t.Minutes <= mins && !d.ClaimedTiers.Contains(t.Tier)); c.Add(MkButton("giftcenter.online", openCount > 0 ? $"领取已达成档位({openCount})" : "领取已达成档位", bx, by0, bx + 230, by1, 15, openCount > 0 ? "0.62 0.40 0.12 0.97" : "0.18 0.20 0.22 0.95", openCount > 0 ? "1 0.96 0.86 1" : "0.60 0.62 0.64 1", s), PanelName); bx += 242; } c.Add(MkButton("giftcenter.close", "关闭面板", bx, by0, bx + 110, by1, 14, "0.30 0.11 0.11 0.95", "0.94 0.86 0.86 1", s), PanelName); string hint; if (UseImages() && _config.Assets.ImageBaseUrl.Trim().StartsWith("http://", StringComparison.OrdinalIgnoreCase)) hint = "图床是 HTTP → 图片库会改写成 https,图拉不到"; else if (!UseImages() && !UseEmbedded()) hint = "没有素材 → 当前为纯色玻璃风"; else if (tab == 1) hint = vipLine(player); else if (tab == 0) hint = "新手礼包一人一次,过时不补"; else if (tab == 2) hint = "按服务器时间统计,改本地时间无效"; else hint = "客服不会索要账号密码,谨防冒充"; c.Add(MkLabel(hint, 560, by0, ColX1, by1, 11, TextAnchor.MiddleRight, "0.62 0.60 0.55 1", s), PanelName); } private string vipLine(BasePlayer player) { return IsVip(player) ? "VIP 双倍已生效" : $"连签 7 天一轮,第 7 天是大奖 | VIP 可领 ×{_config.Settings.VipMultiplier:0.#}"; } #endregion } }