using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries.Covalence; using Oxide.Core.Plugins; using Oxide.Game.Rust.Cui; using UnityEngine; // ★ 授权模块要用 webrequest 的 RequestMethod 枚举(它在 Oxide.Core.Libraries 命名空间下)。 // 这里用「别名」而不是整包 using —— 整包会把同命名空间里的 Time 一起引进来,与 UnityEngine.Time // 撞名报 'Time' is an ambiguous reference(TCBuild v1.1.0 真机报过)。本插件 Time. 命中 0 处, // 但别名写法对它 100% 安全,所以直接上别名,别改成整包引入。 using RequestMethod = Oxide.Core.Libraries.RequestMethod; namespace Oxide.Plugins { [Info("经济中心 EcoCenter", "薯片", "1.1.2")] [Description("经济中心 —— 金币/钻石双货币钱包 + 银行面板 + 管理后台 + 对外接口(原创自研)")] public class EcoCenter : RustPlugin { /* * EcoCenter 经济中心 v1.1.2 — 薯片 版权所有 * =========================================================================== * 一个插件装下全部:双货币钱包 + 银行面板 + 管理后台 + 对外接口。 * 不依赖任何第三方经济插件(uMod Economics 已按需卸载删除),完全原创。 * * 【两种货币】 * 金币 gold —— 玩出来的钱:采集 / 击杀 / 回收 / 在线时长 / 商城卖出 * 钻石 diamond —— 充值来的钱:微信充值(服主发放)+ 每日签到 * ★ 两条红线(写死在代码里,不给买家留坑): * ① 两种货币**不互兑** —— 一旦能互相兑换,玩家就能挂机刷金币换钻石, * 充值收入直接归零; * ② 钻石**只有充值 + 签到**两个来源,绝不出现"用时间无限刷钻石"的口子。 * * 【面板】960x600,7 个页签(配色 = 乙案「军械库」) * ① 银行 今日收入 / 今日支出 / 存款合计(金币、钻石两行) * ② 收支 流水明细(时间·来源·说明·金额·变动后余额),可按币种切换、翻页 * ③ 赚金币 每条赚币途径的单价 + 今日已赚 / 每日上限 * ④ 充值签到 上半 = 每日签到;下半 = 微信收款码 + 兑换码 + 客服 * ⑤ 财富排行 金币榜 / 钻石榜(可切换),前 20 名 * ⑥ 管理后台 仅 ecocenter.admin 可见:给玩家充/扣/改余额、查流水、全服报表、导出 * ⑦ 使用说明 怎么赚、怎么花、怎么充值、常见问题 * * 【性能七条铁律】(写进代码,交付前逐条实测) * ① 绝不挂 OnPlayerTick(每玩家每秒 × 人数 = 最毒写法);只用事件型钩子 * ② ★ 写盘节流:数据常驻内存 + _dirty 标记,只在 OnServerSave + Unload 落盘。 * 绝不为每笔交易写 JSON —— 那是经济插件最大的坑(玩家一多直接拖爆磁盘 IO) * ③ 在线时长工资只开一个 timer.Every(60s),遍历在线玩家数组(几十个),不遍历实体 * ④ 任何高频钩子里都不遍历全服实体 * ⑤ 流水按天分桶 + 自动裁剪(默认只留 N 天) * ⑥ 面板一次 AddUi 整体下发,关闭立刻 DestroyUi(CUI 只在打开时占内存) * ⑦ 玩家数据惰性加载,不预建全服玩家 * * 【对外接口】给以后写的插件(商城 / 悬赏 / 任务 / 抽奖…)调用。 * ★ 核心约定:**每次加减钱都必须带「原因」**,面板才能显示"在商城买了什么"。 * EcoAdd(userId, "gold", 300, "商城卖出 石头x300") * EcoTake(userId, "gold", 600, "商城购买 火药x1") * 第三方商城不认识我们的接口 → 面板只能标「外部变动」。 * * 【充值】★ 预留商户接口(v1.1.0 接微信支付商户号时零改动对接) * 所有到账(手动发放 / 兑换码 / 以后微信回调 / 其他平台) * 统一走同一个入口: * ecocenter.recharge <订单号> <钻石数> <备注> * 订单号幂等 —— 同一个订单号只发一次,防重放。 * 以后服务器收到微信支付回调,只要 RCON 调这条命令即可,插件不用改。 * * 【命令】 * /bank 打开银行面板(/ecc 同义) * /gold /diamond 查自己余额 * /pay <玩家> <数量> 给对方转金币(可配开关) * 控制台 / 管理: * ecocenter.give <数量> [说明] * ecocenter.take <数量> [说明] * ecocenter.set <数量> [说明] * ecocenter.recharge <订单号> <钻石数> [备注] ★ 商户/手动统一入口 * ecocenter.order <订单号> 查一笔订单 * ecocenter.code <数量> [张数] 批量生成钻石兑换码 * ecocenter.export [金币|钻石] 导出排行榜 CSV * ecocenter.stats 全服经济报表 * ecocenter.diag 性能自检(有没有 OnPlayerTick / 写盘次数 / 内存) * ecocenter.auth ★ 插件授权(不带参数 = 只看状态) * * 【授权】★ v1.1.0 起是「软锁」:未授权时插件照常加载、不报错、不卸载, * 但所有功能入口都不放行(管理员看到激活说明页,普通玩家静默拒绝)。 * 控制台 / RCON ecocenter.auth PL-XXXX-XXXX-XXXX * 聊天框 /bank auth PL-XXXX-XXXX-XXXX * 或填 config「授权 Authorize -> 插件授权码 LicenseCode」再 oxide.reload EcoCenter * * 【权限】 * ecocenter.admin 看得到"管理后台"页签 + 用管理命令(默认给自己) * ecocenter.vip 签到与在线工资双倍(可选) * * 【存储】oxide/data/EcoCenter/players.json(钱包 + 流水)、orders.json(充值订单) * 【配置】oxide/config/EcoCenter.json(改完 oxide.reload EcoCenter) * * =========================================================================== * 文件结构(方便以后维护,按 region 折叠): * [常量 / 配色] [配置类] [数据类] [存档] [生命周期] * [钱包核心] [对外 API] [赚币钩子] [签到] [兑换码] * [面板 CUI 骨架] [7 个页签] [管理后台] [授权] [诊断] * =========================================================================== */ #region ===== 常量 / 配色 ===== // ★ v1.1.2:版本号【只写一处】——就是上面的 [Info]。 // 以前这里还挂着一个 private const string Version,等于版本号写两处, // v1.1.1 就漏改了它,导致后台天天误报「你当前是 v1.1.0」。 // 现在下面所有 Version 都走插件基类自带的那份(直接读 [Info],永远同步), // 以后改版本只需要动 [Info] 一行,别再往这里补第二个数字。 // ---- 货币标识(对外 API 与流水都用这两个字符串,不区分大小写匹配)---- public const string CurGold = "gold"; public const string CurDiamond = "diamond"; // ---- 权限 ---- private const string PermAdmin = "ecocenter.admin"; private const string PermVip = "ecocenter.vip"; // ---- 插件授权(v1.1.0 新增;与另外四支自研插件同一套移植源,只改 4 个占位符)---- private const string AuthApiBase = "http://lic.biubr.com"; // 授权服务地址,换服只改这一处 private const string AuthPluginId = "ecocenter"; // 必须与后台插件清单的 plugin_id 一致 private const string AuthCacheFile = "EcoCenter.auth"; // -> oxide/data/EcoCenter.auth.json private const float AuthRetrySeconds = 300f; // 未授权时每 5 分钟自动重试 private const float AuthRenewHours = 6f; // ★ v1.1.1:常驻复核间隔,每 6h 主动问一次服务器 private const float AuthNudgeSeconds = 600f; // 未授权时往控制台写提醒的间隔 // ---- CUI 名称(同容器内必须唯一)---- private const string PanelName = "ecocenter.panel"; private const string CmdUi = "ecocenter.ui"; // 前端按钮统一前缀 // ---- 面板尺寸(v1.0.1 起放大到 1000x620:留白和字距都松快,不再挤成一坨)---- private const float BaseW = 1000f; private const float BaseH = 620f; private const float CornerSize = 64f; // 四角饰件,1:1 不缩放 // ---- 布局坐标(★ CUI 坐标系:左下角为原点,y 越大越靠上)---- private const float MenuX0 = 16f, MenuX1 = 158f, MenuY0 = 16f, MenuY1 = 558f; private const float BodyX0 = 170f, BodyX1 = 984f, BodyY0 = 16f, BodyY1 = 558f; private const float TabH = 38f, TabGap = 5f; // 左菜单按钮高 / 间距(★ 必须放下 7 项 + 钱包卡,见 BuildMenu) private const float BannerY0 = 566f, BannerY1 = 614f; private const float RowsPerPage = 8f; // 流水每页行数 private const float FootY0 = 22f, FootY1 = 50f; // 页脚提示条(各页统一走 Foot()) private const float BodyTop = 506f; // 内容区可用上沿(页头下划线以下) // ---- ★ 配色:默认「石墨金 graphite」(近黑冷灰 + 琥珀金,对比度高、不糊) // 买家可在 config 的「面板配色 Theme → 配色预设 Preset」里整套换掉,见 ThemePresets ---- private const string ColRoot = "0.055 0.059 0.066 0.985"; // #0E0F11 面板底(近黑冷灰,不带绿味) private const string ColCard = "0.106 0.114 0.125 0.96"; // #1B1D20 卡片底(明显亮于底) private const string ColCardSoft = "0.082 0.090 0.102 0.96"; // #15171A 次级卡片 / 菜单未选中 private const string ColLine = "0.208 0.220 0.243 1"; // #35383E 描边 / 分隔线 private const string ColBanner = "0.078 0.086 0.098 0.98"; // #141619 横幅底 private const string ColAccent = "0.949 0.780 0.376 1"; // #F2C760 主色(琥珀) private const string ColAccentBg = "0.345 0.267 0.086 0.98"; // #58441A 按钮 / 选中底(压暗,托字) private const string ColOnAccent = "1 0.953 0.843 1"; // #FFF3D7 选中文字 private const string ColText = "0.945 0.937 0.914 1"; // #F1EFE9 主文字(提亮到接近纯白) private const string ColTextDim = "0.635 0.647 0.667 1"; // #A2A5AA 次文字(原 #96A08F 太暗) private const string ColGold = "0.965 0.812 0.435 1"; // #F6CF6F 金币 private const string ColDiamond = "0.435 0.831 0.910 1"; // #6FD4E8 钻石 private const string ColIn = "0.404 0.800 0.537 1"; // #67CC89 进账 private const string ColOut = "0.910 0.443 0.376 1"; // #E87160 支出 private const string ColWarn = "0.965 0.769 0.400 1"; // 警告文字 private const string ColVeil = "0.016 0.020 0.027 0.72"; // 背景压暗层(压得很重,图退到后面) // ---- 游戏内置贴图(客户端必存在,兜底用;取自 Bundle 实测路径,大小写别改)---- private const string SpBgTile = "assets/content/ui/ui.background.tile.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 SpGradUp = "assets/content/ui/ui.gradient.up.psd"; private const string SpGradDown = "assets/content/ui/ui.gradient.down.psd"; private const string SpLineDot = "assets/content/ui/ui.line.dotted.tga"; private const string SpDarkBg = "assets/content/ui/facepunch-darkbg.png"; private const string SpRustIcon = "assets/content/ui/ui.icon.rust.png"; // 图片元素名必须唯一,否则同容器内互相覆盖(GiftCenter 白块的成因之一) private static int _imgSeq; #endregion #region ===== 配置类 ===== private class PluginConfig { [JsonProperty("设置 Settings")] public SettingsCfg Settings = new SettingsCfg(); [JsonProperty("赚金币 Earn")] public EarnCfg Earn = new EarnCfg(); [JsonProperty("签到 SignIn")] public SignInCfg SignIn = new SignInCfg(); [JsonProperty("充值 Recharge")] public RechargeCfg Recharge = new RechargeCfg(); [JsonProperty("★商户接口(预留·以后接微信支付用) Merchant")] public MerchantCfg Merchant = new MerchantCfg(); [JsonProperty("流水 Records")] public RecordCfg Records = new RecordCfg(); [JsonProperty("面板配色 Theme")] public ThemeCfg Theme = new ThemeCfg(); [JsonProperty("调试 Debug")] public DebugCfg Debug = new DebugCfg(); [JsonProperty("授权 Authorize")] public AuthData Authorize = new AuthData(); } // v1.1.0 插件授权配置。授权码留空 = 未授权(面板只显示激活说明页)。 // ★★ 这个类必须和 PluginConfig **平级**(跟上面 SettingsCfg / EarnCfg 一样), // 千万别塞进 PluginConfig 的大括号里面 —— 那样它就成了 PluginConfig 的私有嵌套类, // 而引用它的字段是 public,真机直接编译失败(2026-09-16 实际踩到,原文照抄在下面): // [Error] Error while compiling EcoCenter: Inconsistent accessibility: // field type 'EcoCenter.PluginConfig.AuthData' is less accessible than // field 'EcoCenter.PluginConfig.Authorize' | Line: 194, Pos: 60 private class AuthData { [JsonProperty("插件授权码 LicenseCode")] public string LicenseCode = ""; [JsonProperty("离线宽限天数 OfflineGraceDays")] public int OfflineGraceDays = 3; // ★ v1.1.1:7 -> 3,只在「连不上服务器」时才启用 [JsonProperty("启动时检查新版本 CheckUpdate")] public bool CheckUpdate = true; } private class SettingsCfg { [JsonProperty("面板标题 PanelTitle")] public string PanelTitle = "经济中心"; [JsonProperty("面板副标题 PanelSubtitle")] public string PanelSubtitle = "荒原银行特殊商店"; [JsonProperty("面板缩放 PanelScale (0.8 - 1.3,1 = 960x600)")] public float PanelScale = 1.0f; [JsonProperty("底部提示 FooterText")] public string FooterText = "聊天框输入 /bank 随时打开经济中心"; [JsonProperty("金币名 GoldName")] public string GoldName = "金币"; [JsonProperty("钻石名 DiamondName")] public string DiamondName = "钻石"; [JsonProperty("金币单位符号 GoldSymbol")] public string GoldSymbol = "金币"; [JsonProperty("钻石单位符号 DiamondSymbol")] public string DiamondSymbol = "钻"; [JsonProperty("新玩家起始金币 StartingGold")] public double StartingGold = 1000.0; [JsonProperty("新玩家起始钻石 StartingDiamond")] public double StartingDiamond = 0.0; [JsonProperty("金币上限 GoldCap (0 = 不限)")] public double GoldCap = 0.0; [JsonProperty("钻石上限 DiamondCap (0 = 不限)")] public double DiamondCap = 0.0; [JsonProperty("允许玩家互相转金币 AllowTransfer")] public bool AllowTransfer = true; [JsonProperty("转账最低额 TransferMin")] public double TransferMin = 1.0; [JsonProperty("转账抽税百分比 TransferTaxPercent (0 = 不抽)")] public double TransferTaxPercent = 0.0; [JsonProperty("进服自动弹面板 PopupOnConnect")] public bool PopupOnConnect = false; [JsonProperty("弹面板延迟秒数 PopupDelaySeconds")] public float PopupDelaySeconds = 5f; [JsonProperty("赚币时聊天框提示(采集/击杀/回收/在线)EarnChatNotify")] public bool EarnChatNotify = true; [JsonProperty("收益提示门槛(累计满这么多才提示一条,0 = 每次都提示)EarnChatNotifyMin")] public double EarnChatNotifyMin = 300.0; [JsonProperty("今日赚满后停止提示 StopTipWhenFull")] public bool StopTipWhenFull = true; [JsonProperty("在线工资也算进聊天提示 OnlineInChatNotify")] public bool OnlineInChatNotify = true; [JsonProperty("赚到大钱全服广播 BroadcastOnBigEarn")] public bool BroadcastOnBigEarn = false; [JsonProperty("广播门槛金额 BroadcastThreshold")] public double BroadcastThreshold = 500.0; } private class EarnCfg { [JsonProperty("★总开关 Enable")] public bool Enable = true; [JsonProperty("采集资源 EnableGather")] public bool EnableGather = true; [JsonProperty("采集每份给金币 GoldPerGatherUnit")] public double GoldPerGatherUnit = 1.0; [JsonProperty("采集每日上限 GatherDailyCap (0 = 不限)")] public double GatherDailyCap = 3000.0; [JsonProperty("采集只算矿石和树木 OnlyOreAndTree")] public bool OnlyOreAndTree = false; [JsonProperty("击杀玩家 EnableKill")] public bool EnableKill = true; [JsonProperty("击杀玩家给金币 GoldPerPlayerKill")] public double GoldPerPlayerKill = 25.0; [JsonProperty("击杀每日上限 KillDailyCap (0 = 不限)")] public double KillDailyCap = 500.0; [JsonProperty("击杀NPC给金币 GoldPerNpcKill (0 = 关)")] public double GoldPerNpcKill = 0.0; [JsonProperty("回收物品 EnableRecycle")] public bool EnableRecycle = true; [JsonProperty("回收每件给金币 GoldPerRecycleItem")] public double GoldPerRecycleItem = 2.0; [JsonProperty("回收每日上限 RecycleDailyCap (0 = 不限)")] public double RecycleDailyCap = 1000.0; [JsonProperty("在线时长 EnableOnline")] public bool EnableOnline = true; [JsonProperty("每 N 秒结算一次 OnlineIntervalSeconds")] public double OnlineIntervalSeconds = 60.0; [JsonProperty("每分钟在线给金币 GoldPerOnlineMinute")] public double GoldPerOnlineMinute = 2.0; [JsonProperty("在线每日上限 OnlineDailyCap (0 = 不限)")] public double OnlineDailyCap = 2000.0; [JsonProperty("下线/重启不丢时长(按秒累计) KeepOnlineSeconds")] public bool KeepOnlineSeconds = true; [JsonProperty("只给开荒期 N 天内发钱 (0 = 一直发) OnlyfirstDays")] public int OnlyfirstDays = 0; } private class SignInCfg { [JsonProperty("★启用 Enable")] public bool Enable = true; [JsonProperty("页签里显示的标题 TabTitle")] public string TabTitle = "每日签到"; [JsonProperty("每人每天只能签一次 OncePerDay")] public bool OncePerDay = true; [JsonProperty("掉签后从第 1 天重来 ResetOnMiss")] public bool ResetOnMiss = true; [JsonProperty("VIP 双倍 VipMultiplier")] public double VipMultiplier = 2.0; [JsonProperty("领取时全服广播 BroadcastOnClaim")] public bool BroadcastOnClaim = true; [JsonProperty("广播内容 BroadcastMessage ({player}/{day}/{amount})")] public string BroadcastMessage = "{player} 完成第 {day} 天签到,领到 {amount} 钻石!"; // ★ ObjectCreationHandling.Replace 必须加 —— 否则 Newtonsoft 反序列化集合时默认是 // 「往已有实例里 Add」,而这个字段的初始化器里有 7 条默认奖励, // 于是每次 reload 配置都在屁股后面再追加 7 条:7 → 14 → 21 … 实测已涨到 147 条。 [JsonProperty("★7 天循环奖励(钻石)Rewards", ObjectCreationHandling = ObjectCreationHandling.Replace)] public List Rewards = new List { new SignReward { Day = 1, Diamonds = 10, Note = "第 1 天" }, new SignReward { Day = 2, Diamonds = 15, Note = "第 2 天" }, new SignReward { Day = 3, Diamonds = 20, Note = "第 3 天" }, new SignReward { Day = 4, Diamonds = 25, Note = "第 4 天" }, new SignReward { Day = 5, Diamonds = 30, Note = "第 5 天" }, new SignReward { Day = 6, Diamonds = 40, Note = "第 6 天" }, new SignReward { Day = 7, Diamonds = 80, Note = "第 7 天(大奖)" }, }; } private class SignReward { [JsonProperty("第几天 Day")] public int Day; [JsonProperty("给多少钻石 Diamonds")] public double Diamonds; [JsonProperty("备注 Note")] public string Note = ""; } private class RechargeCfg { [JsonProperty("★启用充值页 Enable")] public bool Enable = true; [JsonProperty("页面标题 TabTitle")] public string TabTitle = "充值签到"; [JsonProperty("充值说明 PaymentText")] public string PaymentText = "① 用微信扫下面这张收款码付款,金额随意(建议按下表套餐)\n" + "② 付款备注里写上【你的游戏名】,方便服主核对\n" + "③ 付款后把截图发给服主,钻石马上到账(一般 1 分钟内)\n" + "④ 也可以找服主要一个兑换码,自己在这里输入领取"; [JsonProperty("★微信收款码图片文件名 WechatQrImage")] public string WechatQrImage = "pay-wechat.png"; [JsonProperty("图片说明(图放在 oxide/data/EcoCenter/assets/ 里) QrHint")] public string QrHint = "微信扫码付款"; [JsonProperty("客服微信 SupportWechat")] public string SupportWechat = ""; [JsonProperty("客服QQ SupportQQ")] public string SupportQQ = ""; [JsonProperty("客服说明 SupportText")] public string SupportText = "付款后请联系服主,秒到账"; [JsonProperty("★充值套餐 Packages")] public List Packages = new List { new Package { Name = "小试身手", Rmb = 10, Diamonds = 100, Bonus = 0 }, new Package { Name = "小有积蓄", Rmb = 30, Diamonds = 330, Bonus = 30 }, new Package { Name = "腰缠万贯", Rmb = 68, Diamonds = 800, Bonus = 120 }, new Package { Name = "富甲一方", Rmb = 128, Diamonds = 1600, Bonus = 320 }, new Package { Name = "废土首富", Rmb = 328, Diamonds = 4500, Bonus = 1200 }, }; [JsonProperty("兑换码前缀 CodePrefix")] public string CodePrefix = "ECO"; [JsonProperty("兑换码有效期天数 CodeExpireDays (0 = 永久)")] public int CodeExpireDays = 0; } private class Package { [JsonProperty("名称 Name")] public string Name = "套餐"; [JsonProperty("人民币 RMB")] public double Rmb = 10; [JsonProperty("钻石 Diamonds")] public double Diamonds = 100; [JsonProperty("额外赠送 Bonus")] public double Bonus = 0; } // ★★ 商户接口(预留)—— v1.1.0 接微信支付商户号时,改这里就行,插件不用动 private class MerchantCfg { [JsonProperty("★启用商户自动到账 EnableMerchantApi")] public bool EnableMerchantApi = false; [JsonProperty("说明 Note")] public string Note = "① 现在【关闭】,充值走「微信收款码 + 服主手动发放 + 兑换码」,不需要任何资质。" + "② 以后办了微信支付商户号(无营业执照可办「小微商户」),把你的支付回调接到本插件的" + "订单入口即可:订单号 → 钻石 → 到账,幂等防重放。" + "③ 投递方式(三选一,都是同一个入口):" + " · RCON 命令 ecocenter.recharge <订单号> <钻石数> <备注>" + " · 在管理后台点「按订单发放」" + " · 让服务器脚本直接写 orders.json(不推荐,建议走命令)。"; [JsonProperty("★订单投递命令模板 OrderCommand")] public string OrderCommand = "ecocenter.recharge <订单号> <钻石数> <备注>"; [JsonProperty("商户号 MchId (预留)")] public string MchId = ""; [JsonProperty("API v3 密钥 ApiV3Key (预留)")] public string ApiV3Key = ""; [JsonProperty("回调地址 NotifyUrl (预留)")] public string NotifyUrl = ""; [JsonProperty("★人民币与钻石的兑换比例 (1 元 = N 钻石) RmbToDiamond")] public double RmbToDiamond = 10.0; [JsonProperty("允许外部订单覆盖已发放的订单 (危险,别开) AllowOverwriteOrder")] public bool AllowOverwriteOrder = false; } private class RecordCfg { [JsonProperty("保留最近多少天 KeepDays")] public int KeepDays = 7; [JsonProperty("每人每天最多记多少条 MaxPerDay")] public int MaxPerDay = 200; [JsonProperty("面板收支页每页行数 RowsPerPage")] public int RowsPerPage = 8; [JsonProperty("记录低于此金额的小额收支 (0 = 全记) MinAmount")] public double MinAmount = 0.0; [JsonProperty("★同类流水合并秒数 (0 = 不合并;默认 300 = 同一来源 5 分钟内的进账并成一条) FlowMergeSeconds")] public double FlowMergeSeconds = 300.0; } private class ThemeCfg { [JsonProperty("说明 Note")] public string Note = "★ 想整套换色:只改下面第一行「配色预设 Preset」即可,四套都是配好的。" + " graphite=石墨金(默认,冷灰+琥珀)/ arsenal=军械库(原来的绿黑+沙黄)/" + " wasteland=废土(褐黑+铁锈橙)/ ocean=深海(蓝黑+青)。" + " 想微调再填下面任意单项(CUI 格式 \"R G B\" 或 \"R G B A\",0~1),留空 = 用预设值。"; [JsonProperty("★配色预设 Preset (graphite / arsenal / wasteland / ocean)")] public string Preset = "graphite"; [JsonProperty("主色 Accent")] public string Accent = ""; [JsonProperty("面板底色 Root")] public string Root = ""; [JsonProperty("卡片底色 Card")] public string Card = ""; [JsonProperty("次级卡片底 CardSoft")] public string CardSoft = ""; [JsonProperty("按钮/选中底 AccentBg")] public string AccentBg = ""; [JsonProperty("主文字 Text")] public string Text = ""; [JsonProperty("次文字 TextDim")] public string TextDim = ""; [JsonProperty("描边线 Line")] public string Line = ""; } private class DebugCfg { [JsonProperty("打印写盘/交易日志 DebugLog")] public bool DebugLog = false; [JsonProperty("启动时打印性能自检 StartupDiag")] public bool StartupDiag = true; } #endregion #region ===== 数据类 ===== private class PlayerWallet { [JsonProperty("金币 Gold")] public double Gold; [JsonProperty("钻石 Diamond")] public double Diamond; [JsonProperty("累计赚金币 TotalEarnedGold")] public double TotalEarnedGold; [JsonProperty("累计花金币 TotalSpentGold")] public double TotalSpentGold; [JsonProperty("累计获得钻石 TotalEarnedDiamond")] public double TotalEarnedDiamond; [JsonProperty("累计花钻石 TotalSpentDiamond")] public double TotalSpentDiamond; [JsonProperty("最后签到日期 LastSignDate")] public string LastSignDate = ""; [JsonProperty("连签天数 SignStreak")] public int SignStreak; [JsonProperty("累计签到次数 SignTotal")] public int SignTotal; [JsonProperty("历史最高连签 BestStreak")] public int BestStreak; [JsonProperty("今日统计日期 TodayDate")] public string TodayDate = ""; [JsonProperty("今日采集已赚 TodayGather")] public double TodayGather; [JsonProperty("今日击杀已赚 TodayKill")] public double TodayKill; [JsonProperty("今日回收已赚 TodayRecycle")] public double TodayRecycle; [JsonProperty("今日在线已赚 TodayOnline")] public double TodayOnline; [JsonProperty("今日总收入 TodayIn")] public double TodayIn; [JsonProperty("今日总支出 TodayOut")] public double TodayOut; [JsonProperty("今日在线秒数 TodayOnlineSeconds")] public double TodayOnlineSeconds; [JsonProperty("累计在线秒数 TotalOnlineSeconds")] public double TotalOnlineSeconds; [JsonProperty("首次进服 FirstSeen")] public string FirstSeen = ""; [JsonProperty("最后在线 LastSeen")] public string LastSeen = ""; [JsonProperty("最后玩家名 LastName")] public string LastName = ""; // ★ 在线工资的秒数游标:上次结算到哪一秒(避免重复发钱 / 断线重连丢时长) [JsonProperty("在线结算游标 OnlineCursor")] public double OnlineCursor; } private class FlowRecord { [JsonProperty("时间 t")] public string T = ""; [JsonProperty("币种 c")] public string C = CurGold; [JsonProperty("金额 a")] public double A; [JsonProperty("变动后余额 b")] public double B; [JsonProperty("来源 s")] public string S = ""; [JsonProperty("说明 n")] public string N = ""; // ★ 写这条流水的时刻(unix 秒)。用来判断"同类小额流水要不要并成一条" [JsonProperty("时刻 ts")] public double Ts; } private class PlayerFlow { // key = yyyy-MM-dd,按天分桶 → 裁剪时整桶丢掉,不用遍历 [JsonProperty("按天 Days")] public Dictionary> Days = new Dictionary>(); [JsonProperty("流水页码 Page")] public int Page; } private class RechargeOrder { [JsonProperty("订单号 Id")] public string Id = ""; [JsonProperty("玩家 Uid")] public ulong Uid; [JsonProperty("玩家名 Name")] public string Name = ""; [JsonProperty("钻石 Diamonds")] public double Diamonds; [JsonProperty("人民币 Rmb")] public double Rmb; [JsonProperty("渠道 Channel")] public string Channel = "manual"; // manual / code / wechat / other [JsonProperty("状态 Status")] public string Status = "done"; // done / pending [JsonProperty("时间 Time")] public string Time = ""; [JsonProperty("备注 Note")] public string Note = ""; } private class RedeemCode { [JsonProperty("码 Code")] public string Code = ""; [JsonProperty("钻石 Diamonds")] public double Diamonds; [JsonProperty("人民币 Rmb")] public double Rmb; [JsonProperty("生成时间 Created")] public string Created = ""; [JsonProperty("过期时间 Expire")] public string Expire = ""; [JsonProperty("使用者 UsedBy")] public ulong UsedBy; [JsonProperty("使用时间 UsedAt")] public string UsedAt = ""; [JsonProperty("备注 Note")] public string Note = ""; } // 兑换码库存:key = 码 private Dictionary _codes = new Dictionary(); // 充值订单:key = 订单号(★ 幂等:同一订单号只发一次,防重放) private Dictionary _orders = new Dictionary(); #endregion #region ===== 存档(★ 写盘节流:内存常驻 + _dirty 标记)===== private const string DataDir = "EcoCenter"; private const string FilePlayers = "EcoCenter/players"; private const string FileFlow = "EcoCenter/flow"; private const string FileOrders = "EcoCenter/orders"; private const string FileCodes = "EcoCenter/codes"; private Dictionary _wallets = new Dictionary(); private Dictionary _flows = new Dictionary(); private PluginConfig _config; private bool _dirty; // 钱包/流水有变动 → 待落盘 private bool _ordersDirty; private double _lastSaveAt; private int _saveCount; // 诊断用:本次运行写盘次数 private int _txCount; // 诊断用:本次运行交易笔数 private string _today = ""; // 缓存"今天",避免每笔交易都格式化时间 // ---- 授权运行时状态(v1.1.0)---- private bool _unloaded; // 已卸载,异步回调直接丢弃(否则卸载后 NRE 刷屏) private bool _authorized; // 当前是否已授权(false 时加钱漏斗与面板全拦住) private bool _authPending; // 正在联网校验中,避免重复请求 private AuthCache _authCache; // 本地授权缓存 private string _authHint = "尚未校验"; // 面板上显示的授权状态说明 private string _latestVersion = ""; // 后台登记的最新版本 private string _updateHint = ""; // 版本落后提示 private int _nudgeCount; // 未授权提醒次数(上限 3 次) private readonly Dictionary _authPanelAt = new Dictionary(); // 激活页节流 // ★ v1.1.0:补上 sync=true。少了它,关服瞬间改的配置可能没落盘(2026-09-16 上架前终审的 P1 项) protected override void SaveConfig() => Config.WriteObject(_config, true); // ------------------------------------------------------------------ // ★ v1.1.2 修复:签到奖励被反复追加。 // 上面的 ObjectCreationHandling.Replace 负责「以后不再涨」, // 这一段负责「把历史上已经涨出来的收回」——老客户的配置里可能已经躺着几十上百条, // 插件自己不会把它们变干净,必须主动去重。 // ------------------------------------------------------------------ protected override void LoadConfig() { base.LoadConfig(); try { _config = Config.ReadObject(); if (_config == null) throw new Exception("null config"); if (_config.SignIn == null) _config.SignIn = new SignInCfg(); if (_config.SignIn.Rewards == null) _config.SignIn.Rewards = new List(); int dup = DedupSignRewards(_config.SignIn.Rewards); if (dup > 0) { PrintWarning("[配置自愈] 签到奖励清理掉 " + dup + " 条重复记录(" + _config.SignIn.Rewards.Count + " 条保留)—— 这是 v1.1.1 及更早版本的遗留问题,现已修复。"); SaveConfig(); } } catch (Exception ex) { PrintWarning("配置文件无效(" + ex.Message + "),已重建默认配置"); _config = new PluginConfig(); SaveConfig(); } } // 按「第几天」保序去重:同一天只允许有一条记录,保留先出现的那条。 private static int DedupSignRewards(List src) { if (src == null || src.Count < 2) return 0; var seen = new HashSet(); var keep = new List(); foreach (var r in src) { if (r == null) continue; if (seen.Add(r.Day)) keep.Add(r); } int removed = src.Count - keep.Count; if (removed > 0) { src.Clear(); src.AddRange(keep); } return removed; } protected override void LoadDefaultConfig() { _config = new PluginConfig(); SaveConfig(); PrintWarning("已生成默认配置:oxide/config/EcoCenter.json(所有数值都能改,改完 oxide.reload EcoCenter)"); } private void LoadData() { try { _wallets = Interface.Oxide.DataFileSystem .ReadObject>(FilePlayers) ?? new Dictionary(); } catch (Exception e) { PrintError("读取玩家数据失败(已用空数据启动,原文件没动):" + e.Message); _wallets = new Dictionary(); } try { _flows = Interface.Oxide.DataFileSystem .ReadObject>(FileFlow) ?? new Dictionary(); } catch (Exception e) { PrintError("读取流水失败(已跳过):" + e.Message); _flows = new Dictionary(); } try { _orders = Interface.Oxide.DataFileSystem .ReadObject>(FileOrders) ?? new Dictionary(); } catch { _orders = new Dictionary(); } try { _codes = Interface.Oxide.DataFileSystem .ReadObject>(FileCodes) ?? new Dictionary(); } catch { _codes = new Dictionary(); } } /// /// ★ 只在这里写盘。绝不每笔交易写 JSON —— 那是经济插件最大的性能坑。 /// 调用点只有两个:OnServerSave(服务器自动存档)+ Unload(插件卸载/重载)。 /// private void SaveData(bool force = false) { if (!_dirty && !_ordersDirty && !force) return; try { if (_dirty || force) { Interface.Oxide.DataFileSystem.WriteObject(FilePlayers, _wallets); Interface.Oxide.DataFileSystem.WriteObject(FileFlow, _flows); } if (_ordersDirty || force) { Interface.Oxide.DataFileSystem.WriteObject(FileOrders, _orders); Interface.Oxide.DataFileSystem.WriteObject(FileCodes, _codes); } _dirty = false; _ordersDirty = false; _saveCount++; _lastSaveAt = Now(); if (_config?.Debug?.DebugLog == true) Puts($"[存档] 写盘完成(本次运行第 {_saveCount} 次)"); } catch (Exception e) { PrintError("写盘失败:" + e.Message); } } #endregion #region ===== 小工具 ===== private static double Now() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0; private static string Today() => DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); private static string Yesterday() => DateTime.Now.AddDays(-1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); private static string Stamp() => DateTime.Now.ToString("HH:mm", CultureInfo.InvariantCulture); private static string DateStamp() => DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); /// 把 "gold"/"金币"/"钻石"/"diamond" 统一成内部标识;认不出来返回 null public static string NormCur(string cur) { if (string.IsNullOrEmpty(cur)) return null; string c = cur.Trim().ToLowerInvariant(); switch (c) { case "gold": case "g": case "金币": case "金": case "money": return CurGold; case "diamond": case "dia": case "d": case "钻石": case "钻": case "gem": return CurDiamond; default: return null; } } private string CurName(string cur) => cur == CurDiamond ? _config.Settings.DiamondName : _config.Settings.GoldName; private string CurSymbol(string cur) => cur == CurDiamond ? _config.Settings.DiamondSymbol : _config.Settings.GoldSymbol; private string CurColor(string cur) => cur == CurDiamond ? ColDiamond : ColGold; private static string Money(double v) { // 整数不显示小数点,有零头保留两位 if (Math.Abs(v - Math.Round(v)) < 0.005) return Math.Round(v).ToString("N0", CultureInfo.InvariantCulture); return v.ToString("N2", CultureInfo.InvariantCulture); } private float Scale() { float s = _config?.Settings?.PanelScale ?? 1f; return Mathf.Clamp(s, 0.8f, 1.3f); } 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 bool IsAdmin(BasePlayer p) => p != null && (p.IsAdmin || permission.UserHasPermission(p.UserIDString, PermAdmin)); private bool IsVip(BasePlayer p) => p != null && permission.UserHasPermission(p.UserIDString, PermVip); #endregion // ---- 未授权时的激活说明页(软锁:不报错、不卸载,只讲怎么激活)---- private void ShowAuthPanel(BasePlayer player) { if (player == null || !player.IsConnected) return; // ★ 节流:同一玩家 1 秒内只弹一次(/bank 连点、按钮连点时防刷屏) float nowT = UnityEngine.Time.realtimeSinceStartup; float lastT; if (_authPanelAt.TryGetValue(player.userID, out lastT) && nowT - lastT < 1f) return; _authPanelAt[player.userID] = nowT; KillUi(player); // 连 .veil 一起拆干净 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 执行: ecocenter.auth PL-XXXX-XXXX-XXXX", "第 3 步 看到「授权成功」后执行 oxide.reload EcoCenter ,七个页签立刻全部生效", "也可以把码填进配置文件「授权 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("ecocenter.ui.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); } // ===================================================================== // ===================================================================== // v1.1.1 授权撤销改造(★ 全项目统一标准,以后新建插件照这个来) // · ★ 加【常驻复核】:每 6 小时主动联网问一次。旧版只在 OnServerInitialized // 判断一次,且距上次成功不足 24h 连请求都不发 -> 作者在后台停用/删码之后, // 插件靠本地宽限能一直跑到过期,撤销最长要等 7 天,等于撤不掉。 // · 离线宽限 7 -> 3 天,且【只在「连不上服务器」时生效】;服务端一旦【明确拒绝】 // (码不存在 / 已停用 / 不是本插件的码 / 台数已满)→ 当场锁,一秒宽限都不给。 // · 服务端明确拒绝时抹掉本地凭据的 last_ok(Code 保留)→ 码恢复正常能自动复活。 // · CacheFresh 加两项:「配置里的码 ≠ 本地凭据的码 -> 旧凭据作废」+ 防系统时间回拨 // (当前时间比上次成功早 6h 以上 = 有人在拨表 -> 不认宽限,强制联网复核)。 // · 新增 ecocenter.deauth 注销命令(清本地凭据 + 清 config 里的码,立刻回未授权)。 // ===================================================================== #region 授权(插件授权码 · 联网校验 · 离线宽限) // ===================================================================== // 【软锁】策略(与《_插件产品化规范与售卖流程.md》§2.4 一致): // · 未授权时插件【照常加载、不报错、不卸载】,命令照常注册 —— // 这样服主能直接用 ecocenter.auth <码> 自救,不会卡在"卸载了就没法激活"的死循环。 // · 只在「功能入口」拦一道(EnsureAuthorized),未授权时只给管理员看激活说明页, // 普通玩家静默拒绝(不刷屏、不报红字,玩家看不出服务器有问题)。 // · 联网校验走 webrequest 异步回调,绝不阻塞服务器主线程。 // · 离线宽限 3 天:作者服务器挂了 / 买家断网,不能把买家的服搞停。 // · 一个授权码能绑几台服务器由授权服务(plugcodes.max_slots)说了算, // 插件侧只负责把服务端拒绝的原因(含「已经绑满 N 台」)原样翻成中文告诉服主。 // ===================================================================== // ---- 本地授权缓存:oxide/data/EcoCenter.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 天,后来代码默认值改成 3 天 —— 但 // config 里躺着的值会赢,导致「宽限收紧到 3 天、让撤权真正生效」 // 这条安全策略,对【升级上来】的老用户根本没落到地上。 // ② 以前这里是 Mathf.Clamp(d, 0, 365),等于买家把 config 改成 365 之后, // 首次激活成功就能离线白用一整年 —— 授权体系直接被架空。 // 夹完之后:老 config 写 7 也会按 3 执行,不需要买家删配置、不需要迁移。 private const int AuthMaxGraceDays = 3; private int GraceDays { get { int d = (_config != null && _config.Authorize != null) ? _config.Authorize.OfflineGraceDays : 3; return Mathf.Clamp(d, 0, AuthMaxGraceDays); } } private static bool TryParseUtc(string s, out DateTime t) { t = DateTime.MinValue; if (string.IsNullOrEmpty(s)) return false; return DateTime.TryParse(s, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out t); } private static string MaskCode(string code) { if (string.IsNullOrEmpty(code)) return "(未填写)"; if (code.Length < 12) return code; return code.Substring(0, 7) + "-****-" + code.Substring(code.Length - 4); } private void LoadAuthCache() { try { var f = Interface.Oxide.DataFileSystem.GetFile(AuthCacheFile); _authCache = (f != null && f.Exists()) ? f.ReadObject() : null; } catch (Exception ex) { PrintWarning("授权缓存读取失败(忽略,稍后重新联网校验): " + ex.Message); _authCache = null; } if (_authCache == null) _authCache = new AuthCache(); } private void SaveAuthCache() { try { Interface.Oxide.DataFileSystem.WriteObject(AuthCacheFile, _authCache); } catch (Exception ex) { PrintWarning("授权缓存保存失败: " + ex.Message); } } // 本地凭据是否还在宽限期内(这就是「服务器断网也能继续用」的依据) private bool CacheFresh() { DateTime t; if (_authCache == null) return false; if (_authCache.PluginId != AuthPluginId) return false; if (!TryParseUtc(_authCache.LastOk, out t)) return false; // ★ 配置里写了另一个码 -> 旧凭据立刻作废(换了码不许旧凭据继续生效) string cfgCode = (_config != null && _config.Authorize != null && _config.Authorize.LicenseCode != null) ? _config.Authorize.LicenseCode.Trim().ToUpperInvariant() : ""; string cacheCode = (_authCache.Code != null) ? _authCache.Code.Trim().ToUpperInvariant() : ""; if (cfgCode.Length > 0 && !string.Equals(cfgCode, cacheCode, StringComparison.OrdinalIgnoreCase)) return false; // ★ 防系统时间回拨 —— 当前时间比上次成功校验还早 6h 以上 = 有人在拨表, // 这时【不认宽限】,强制联网复核(连不上就在 5 分钟重试里自愈)。 if (DateTime.UtcNow < t.AddHours(-6)) return false; return (DateTime.UtcNow - t).TotalDays <= GraceDays; } private string GraceExpireText() { DateTime t; if (_authCache == null || !TryParseUtc(_authCache.LastOk, out t)) return "(无)"; return t.ToLocalTime().AddDays(GraceDays) .ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture); } // 配置里的码优先,其次用上次校验成功的码 private string CurrentCode() { string c = (_config != null && _config.Authorize != null && _config.Authorize.LicenseCode != null) ? _config.Authorize.LicenseCode.Trim() : ""; if (c.Length > 0) return c.ToUpperInvariant(); return (_authCache != null && _authCache.Code != null) ? _authCache.Code.Trim().ToUpperInvariant() : ""; } // ---- 入口:OnServerInitialized 里调用 ---- private void InitAuthorize() { LoadAuthCache(); timer.Every(AuthNudgeSeconds, AuthNudge); // 未授权时提醒在线管理员 timer.Every(AuthRenewHours * 3600f, AuthRenew); // ★ v1.1.1:常驻复核,撤销最长 6h 生效 if (CacheFresh()) { _authorized = true; _authHint = "已授权(本地凭据有效)"; Puts("[授权] 已授权,本地凭据有效期至 " + GraceExpireText() + "(其间断网也能正常使用)。"); DateTime last; if (!TryParseUtc(_authCache.LastOk, out last) || (DateTime.UtcNow - last).TotalHours >= AuthRenewHours) RequestVerify(null, null, false); // 启动时距上次成功已超 6h,先续一次 return; } _authorized = false; if (string.IsNullOrEmpty(CurrentCode())) { _authHint = "未填写授权码"; PrintWarning("本插件尚未授权,功能不会生效(面板只显示激活说明页)。"); PrintWarning("激活方法:配置文件「授权 Authorize → 插件授权码 LicenseCode」填入 PL- 开头的码,"); PrintWarning("或在服务器控制台执行:ecocenter.auth <你的授权码>,然后 oxide.reload EcoCenter。"); PrintAuthStatus(); return; // 没码就不必空转重试,等服主填码后重载 } _authHint = "正在联网校验…"; RequestVerify(null, null, false); } private void RetryAuth() { if (_unloaded || _authorized) return; if (string.IsNullOrEmpty(CurrentCode())) return; RequestVerify(null, null, false); } // ★ 常驻复核。已授权状态也必须定期问服务器一次 —— // 否则作者在后台「停用」或删掉授权码之后,插件靠本地宽限能一直跑到过期, // 等于撤销不了(旧版只在 OnServerInitialized 判一次,且距上次成功不足 24h // 连请求都不发 -> 重启插件 / 重启游戏服 / 删 config 三招全无效)。 private void AuthRenew() { if (_unloaded) return; if (string.IsNullOrEmpty(CurrentCode())) return; RequestVerify(null, null, false); } // 未授权时每隔一段时间往控制台写一条提醒(最多 3 次;★ 不再发聊天消息) private void AuthNudge() { if (_unloaded || _authorized) return; if (_nudgeCount++ >= 3) return; // ★ 2026-09-16 起只写服务器控制台,不进任何人的聊天框: // 薯片反馈三个插件的未授权提示会连着刷聊天框,看着像服务器报错。 // 插件加载时已有完整中文提示;管理员真要用功能时也会弹激活说明面板, // 所以这里只当"后台日志"留个痕迹就够了。 Puts("★ 本插件尚未授权,功能不可用(" + _authHint + ")。" + "激活:控制台执行 ecocenter.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, "还没有授权码。用法:ecocenter.auth PL-XXXX-XXXX-XXXX"); return; } if (webrequest == null) { OnAuthFailed(manual, "插件运行环境不支持联网(webrequest 不可用)", false, manualInput, use); return; } // 故意不上报服务器 IP:IP 一律由授权服务按请求来源判定,杜绝伪造 var payload = new Dictionary { { "code", use }, { "plugin_id", AuthPluginId } }; var headers = new Dictionary { { "Content-Type", "application/json" } }; _authPending = true; Puts("[授权] 正在向授权服务器校验(" + MaskCode(use) + ")…"); webrequest.Enqueue(AuthApiBase + "/api/plugin/verify", JsonConvert.SerializeObject(payload), (status, response) => OnVerifyResponse(status, response, use, manual, manualInput), this, RequestMethod.POST, headers, 15f); } private void OnVerifyResponse(int status, string response, string code, BasePlayer manual, bool manualInput) { _authPending = false; if (_unloaded) return; if (status != 200 || string.IsNullOrEmpty(response)) { // 网络类问题:宽限期内继续放行,绝不能因为一次抖动把买家服务器搞停 OnAuthFailed(manual, status == 0 ? "连不上授权服务器(出网被拦 / 服务临时不可用)" : ("授权服务器返回 HTTP " + status), false, manualInput, code); return; } AuthResp r = null; try { r = JsonConvert.DeserializeObject(response); } catch (Exception) { OnAuthFailed(manual, "授权服务器返回内容无法解析", false, manualInput, code); return; } if (r == null || !r.ok) { // 服务端明确拒绝(码不存在 / 已停用 / 不属于本插件 / 台数已满) string why = (r != null && !string.IsNullOrEmpty(r.error)) ? r.error : "授权校验未通过"; OnAuthFailed(manual, why, true, manualInput, code); return; } // ---- 校验通过 ---- _authorized = true; _nudgeCount = 0; _authCache.Code = code; _authCache.PluginId = AuthPluginId; _authCache.LastOk = DateTime.UtcNow.ToString("o", System.Globalization.CultureInfo.InvariantCulture); _authCache.PluginVer = Version.ToString(); if (string.IsNullOrEmpty(_authCache.BoundAt)) _authCache.BoundAt = _authCache.LastOk; if (r.plugin != null && !string.IsNullOrEmpty(r.plugin.version)) _latestVersion = r.plugin.version; SaveAuthCache(); if (!string.IsNullOrEmpty(_latestVersion) && CompareVer(_latestVersion, Version.ToString()) > 0) SetUpdateHint(_latestVersion); _authHint = "已授权"; Puts("[授权] 校验通过,插件已生效。授权码 " + MaskCode(code) + ",本地凭据有效期至 " + GraceExpireText() + "。"); PrintAuthStatus(); if (manualInput && manual != null) Msg(manual, "授权成功,插件已生效。"); } // serverRejected=true = 服务端明确拒绝,不接受离线宽限 // manualInput=true = 服主刚手填的码 // attempted = 这一次拿去校验的码,用来判断要不要回退 private void OnAuthFailed(BasePlayer manual, string reason, bool serverRejected, bool manualInput, string attempted) { if (_unloaded) return; // 情形一:这次用的码没通过,但本地还存着另一份仍然有效的凭据 // → 自动把配置改回那个有效码并重新校验一次。 // 作用:服主在 config 里手抖打错一个字符,不会把原本正常的服务器搞停。 // 只有「两个码不一样」才回退,所以作者主动停用授权码时不会被绕过。 if (serverRejected && CacheFresh() && !string.IsNullOrEmpty(_authCache.Code) && !string.Equals(_authCache.Code, attempted, StringComparison.OrdinalIgnoreCase)) { string good = _authCache.Code; _config.Authorize.LicenseCode = good; SaveConfig(); PrintWarning("[授权] 这次用的码没通过(" + reason + "),已自动改回原来有效的授权码 " + MaskCode(good) + " 并重新校验。"); if (manual != null) Msg(manual, "这个码没通过(" + reason + "),已改回原来的授权码并重新校验…"); RequestVerify(good, null, false); return; } // 情形二:网络类问题 + 还在宽限期 → 继续放行(买家服务器不能因为一次抖动停摆) if (!serverRejected && CacheFresh()) { _authorized = true; _authHint = "已授权(离线宽限中)"; PrintWarning("[授权] 本次联网校验没成功(" + reason + "),但本地凭据仍在 " + GraceDays + " 天宽限期内,插件继续正常工作。"); if (manual != null) Msg(manual, "联网校验没成功(" + reason + "),但本地凭据仍在宽限期内,功能不受影响。"); timer.Once(AuthRetrySeconds, RetryAuth); return; } // 情形三:真没授权 → 功能不生效 // ★ 服务端【明确拒绝】时抹掉本地凭据的 last_ok(Code / PluginId 保留)—— // 这样「作者停用或删码」之后本地宽限立刻失效;码一旦恢复正常又能在 5 分钟重试里自动复活。 // 注意:网络类失败(serverRejected=false)绝不清凭据,不能因为一次抖动把买家搞停。 if (serverRejected && _authCache != null && !string.IsNullOrEmpty(_authCache.LastOk)) { _authCache.LastOk = ""; SaveAuthCache(); Puts("[授权] 已清除本地凭据(服务端拒绝:\"" + reason + "\"),宽限期不再生效。"); } _authorized = false; _authHint = serverRejected ? ("授权失败:" + reason) : ("暂未授权:" + reason); PrintWarning("[授权] 未通过:" + reason); PrintWarning("请确认授权码格式为 PL-XXXX-XXXX-XXXX,且买的是本插件;" + "重新校验:控制台执行 ecocenter.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("===== 经济中心 EcoCenter 授权状态 ====="); Puts(" 状态:" + (_authorized ? "已授权(功能正常)" : "未授权(功能不生效)")); Puts(" 授权码:" + MaskCode(CurrentCode())); Puts(" 插件版本:" + Version.ToString() + (string.IsNullOrEmpty(_latestVersion) ? "" : ("(后台最新 " + _latestVersion + ")"))); Puts(" 本地凭据:" + (CacheFresh() ? ("有效,至 " + GraceExpireText()) : "无 / 已过期")); Puts(" 主动复核:每 " + AuthRenewHours + " 小时一次;仅「连不上服务器」时才用 " + GraceDays + " 天宽限"); Puts(" 用法:ecocenter.auth (不带参数 = 只看状态)"); Puts(" ecocenter.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); } // ===================================================================== // ---- ★ 控制台 / RCON 授权入口:ecocenter.auth [授权码],不带参数只看状态 ---- // 这一条必须在软锁之外 —— 它是服主"卸载了就没法激活"死循环的唯一出口。 [ConsoleCommand("ecocenter.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(" 重新激活:ecocenter.auth PL-XXXX-XXXX-XXXX"); if (player != null) Msg(player, "已注销,插件回到未授权状态。"); } [ConsoleCommand("ecocenter.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); } // ---- ★ 聊天入口:/bank auth [授权码] 或 /ecc auth [授权码] ---- private void ChatAuth(BasePlayer player, string[] args) { if (player == null) return; if (!IsAdmin(player)) { SendReply(player, "只有服务器管理员能填授权码(这是本插件的激活入口)。"); return; } string code = (args != null && args.Length > 1) ? args[1].Trim() : ""; if (string.IsNullOrEmpty(code)) { SendReply(player, $"当前授权状态:{_authHint} 授权码 {MaskCode(CurrentCode())}" + " (用法:/bank auth PL-XXXX-XXXX-XXXX)"); return; } if (_config == null) _config = new PluginConfig(); if (_config.Authorize == null) _config.Authorize = new AuthData(); _config.Authorize.LicenseCode = code.ToUpperInvariant(); SaveConfig(); SendReply(player, "正在校验授权码,结果会在这里告诉你…"); RequestVerify(code, player, true); } // ---- ★ 软锁守卫(控制台命令用):未授权就回一句中文并拦下 ---- private bool AuthGuardConsole(ConsoleSystem.Arg arg, string what) { if (_authorized) return true; Reply(arg, "本插件尚未授权,不能" + what + "。" + "激活:控制台执行 ecocenter.auth PL-XXXX-XXXX-XXXX 后重载插件"); PrintAuthStatus(); return false; } #endregion #region ===== 生命周期 ===== private void OnServerInitialized() { _config = Config.ReadObject(); if (_config == null) { LoadDefaultConfig(); _config = Config.ReadObject(); } // 配置项补全(老配置升上来时新增的字段不会是 null) if (_config.Settings == null) _config.Settings = new SettingsCfg(); if (_config.Earn == null) _config.Earn = new EarnCfg(); if (_config.SignIn == null) _config.SignIn = new SignInCfg(); if (_config.Recharge == null) _config.Recharge = new RechargeCfg(); if (_config.Merchant == null) _config.Merchant = new MerchantCfg(); if (_config.Records == null) _config.Records = new RecordCfg(); if (_config.Theme == null) _config.Theme = new ThemeCfg(); if (_config.Debug == null) _config.Debug = new DebugCfg(); if (_config.Authorize == null) _config.Authorize = new AuthData(); // v1.1.0:老配置没有这一段 if (_config.SignIn.Rewards == null || _config.SignIn.Rewards.Count == 0) { _config.SignIn.Rewards = new List { new SignReward { Day = 1, Diamonds = 10, Note = "第 1 天" }, new SignReward { Day = 2, Diamonds = 15, Note = "第 2 天" }, new SignReward { Day = 3, Diamonds = 20, Note = "第 3 天" }, new SignReward { Day = 4, Diamonds = 25, Note = "第 4 天" }, new SignReward { Day = 5, Diamonds = 30, Note = "第 5 天" }, new SignReward { Day = 6, Diamonds = 40, Note = "第 6 天" }, new SignReward { Day = 7, Diamonds = 80, Note = "第 7 天(大奖)" }, }; } SaveConfig(); _today = Today(); LoadData(); // 权限 if (!permission.PermissionExists(PermAdmin)) permission.RegisterPermission(PermAdmin, this); if (!permission.PermissionExists(PermVip)) permission.RegisterPermission(PermVip, this); // ★ v1.1.0:授权判定(软锁)。必须放在下面两个 timer 之前 —— // 在线工资定时器最短 10 秒就会跑第一轮,若那时 _authorized 还没定好, // 会出现"未授权却已经在发钱"的窗口。 InitAuthorize(); // 素材目录(放微信收款码等图) try { string dir = Path.Combine(Interface.Oxide.DataDirectory, DataDir, "assets"); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); string readme = Path.Combine(dir, "★素材图放这里.txt"); if (!File.Exists(readme)) { var sb = new StringBuilder(); sb.AppendLine("经济中心 EcoCenter v" + Version + " —— 素材图文件夹"); sb.AppendLine("===================================================="); sb.AppendLine(); sb.AppendLine("把 PNG 图片放进这个文件夹(必须是 PNG),文件名要和 config 里写的一致。"); sb.AppendLine(); sb.AppendLine("【充值页要的图】"); sb.AppendLine(" pay-wechat.png 你的微信收款码(玩家扫码付款用)"); sb.AppendLine(" 名字可以在 config 的「充值 Recharge -> 微信收款码图片文件名」里改"); sb.AppendLine(); sb.AppendLine("【可选:面板美化图】(不放也能用,会走纯色 + 游戏内置贴图)"); sb.AppendLine(" bg-bank.png 面板背景大图(建议 960x600)"); sb.AppendLine(" corner-tl.png 左上角铆钉件(64x64)"); sb.AppendLine(" corner-tr.png 右上角铆钉件(64x64)"); sb.AppendLine(" corner-bl.png 左下角铆钉件(64x64)"); sb.AppendLine(" corner-br.png 右下角铆钉件(64x64)"); sb.AppendLine(" coin.png 金币小图标(24x24,可省)"); sb.AppendLine(" diamond.png 钻石小图标(24x24,可省)"); sb.AppendLine(); sb.AppendLine("★ 三条硬约束(实测,别试别的写法):"); sb.AppendLine(" 1) 只支持 PNG,JPG 会裂图;"); sb.AppendLine(" 2) 带边框的图按最终像素 1:1 出,不要放大缩小(Rust CUI 没有九宫格拉伸);"); sb.AppendLine(" 3) 单张图不要超过 3 MB。"); sb.AppendLine(); sb.AppendLine("放好后在游戏里执行 oxide.reload EcoCenter 立刻生效。"); File.WriteAllText(readme, sb.ToString(), new UTF8Encoding(true)); } } catch (Exception e) { PrintWarning("建素材目录失败(不影响功能):" + e.Message); } // 在线工资定时器(★ 只开这一个 timer,遍历在线玩家数组,不遍历实体) if (_config.Earn.Enable && _config.Earn.EnableOnline) { float every = Mathf.Clamp((float)_config.Earn.OnlineIntervalSeconds, 10f, 300f); timer.Every(every, TickOnline); } // 每天一次的维护:跨天重置 + 流水裁剪 + 自动落盘一次 timer.Every(300f, TickMaintenance); // 素材图注册(图片库加载比本插件晚,内部会自动重试) RegisterImages(); if (_config.Debug.StartupDiag) PrintWarning($"经济中心 v{Version} 已启动:{_wallets.Count} 个钱包 / {_flows.Count} 份流水 / " + $"写盘策略=只在自动存档时(本次已写 {_saveCount} 次)/ 无 OnPlayerTick"); // ★ 启动时再兜一次底:上一次重载如果没走完 Unload,屏幕上的旧面板会因为 // 元素名冲突变成"点不动的僵尸层",这里统一先拆干净 try { var list = BasePlayer.activePlayerList; for (int i = 0; i < list.Count; i++) KillUi(list[i]); } catch { } // 只提醒不拦:落后于后台登记版本就在控制台提示 timer.Once(10f, CheckPluginVersion); Puts($"EcoCenter v{Version} by 薯片 已加载。命令 /bank ,权限 {PermAdmin} / {PermVip}"); } /// ★ 把本插件在某个玩家屏幕上的 UI 全拆掉(重载 / 断线都要用) private void KillUi(BasePlayer p) { if (p == null || !p.IsConnected) return; try { CuiHelper.DestroyUi(p, PanelName); CuiHelper.DestroyUi(p, PanelName + ".veil"); } catch { } } private void Unload() { _unloaded = true; // ★ v1.1.0:异步回调(授权联网校验 / 版本查询)到此直接丢弃 // ★★ 先拆界面再落盘。少了这一步,oxide.reload 之后旧面板会一直赖在玩家屏幕上 // (2026-09-15 用户实际遇到:"插件重载了,游戏界面还停留在插件页面") try { var list = BasePlayer.activePlayerList; for (int i = 0; i < list.Count; i++) KillUi(list[i]); } catch { } // ★ 卸载前必须落盘(否则重启期间的所有交易都会丢) SaveData(true); Puts($"已落盘并卸载。本次运行共写盘 {_saveCount} 次、处理 {_txCount} 笔交易。"); } // ★ 服务器自动存档时落盘 —— 这就是"写盘节流"的落点,绝不每笔交易写一次 private void OnServerSave() => SaveData(); private void OnNewSave(string filename) { Puts($"服务器换了新存档({filename})。经济数据**不会自动清空** —— " + "要清档请手动删 oxide/data/EcoCenter/ 下的文件,或用 ecocenter.stats 先导出。"); } #endregion #region ===== 钱包核心(内存操作,不写盘)===== private static double GetBal(PlayerWallet w, string cur) => cur == CurDiamond ? w.Diamond : w.Gold; private static void SetBal(PlayerWallet w, string cur, double v) { if (cur == CurDiamond) w.Diamond = v; else w.Gold = v; } private static void AddEarnTotal(PlayerWallet w, string cur, double v) { if (cur == CurDiamond) w.TotalEarnedDiamond += v; else w.TotalEarnedGold += v; } private static void AddSpentTotal(PlayerWallet w, string cur, double v) { if (cur == CurDiamond) w.TotalSpentDiamond += v; else w.TotalSpentGold += v; } /// 拿钱包;create=false 时不存在就返回 null(惰性加载,不预建全服玩家) private PlayerWallet GetWallet(ulong uid, bool create = true) { if (_wallets.TryGetValue(uid, out var w)) return w; if (!create) return null; w = new PlayerWallet { Gold = _config.Settings.StartingGold, Diamond = _config.Settings.StartingDiamond, FirstSeen = DateStamp(), TodayDate = _today, }; _wallets[uid] = w; _dirty = true; return w; } /// 跨天就把"今日"统计清零(每个钱包一天只做一次) private void RollDay(PlayerWallet w) { if (w.TodayDate == _today) return; w.TodayDate = _today; w.TodayGather = 0; w.TodayKill = 0; w.TodayRecycle = 0; w.TodayOnline = 0; w.TodayIn = 0; w.TodayOut = 0; w.TodayOnlineSeconds = 0; _dirty = true; } private double CapOf(string cur) => cur == CurDiamond ? _config.Settings.DiamondCap : _config.Settings.GoldCap; /// 查余额(对外 API 也走这个) public double Balance(ulong uid, string cur) { cur = NormCur(cur); if (cur == null) return 0; var w = GetWallet(uid, false); return w == null ? 0 : GetBal(w, cur); } /// /// ★ 加钱(核心入口)。所有加钱都必须带 source 来源 + note 说明, /// 面板「收支」页才能显示"这笔钱哪来的"。 /// public bool Add(ulong uid, string cur, double amount, string source, string note = "") { // ★ v1.1.0 软锁:这是**全插件唯一的加钱漏斗** —— 采集 / 击杀 / 回收 / 在线工资 / // 签到 / 兑换码 / 充值到账 / 管理员发放,全部汇集到这里,所以在这一处拦住就够全。 // ★ 只拦「给出好处」:下面的 Take()(扣钱)故意不拦 —— // 拦住扣钱等于让别人白拿东西,那才是真坑服主。 if (!_authorized) return false; cur = NormCur(cur); if (cur == null || uid == 0 || amount <= 0 || double.IsNaN(amount) || double.IsInfinity(amount)) return false; var w = GetWallet(uid); RollDay(w); double cap = CapOf(cur); double old = GetBal(w, cur); double val = old + amount; if (cap > 0 && val > cap) val = cap; // 撞上限就截断 double real = val - old; if (real <= 0) return false; // 已经满了 if (real < amount && _config.Debug.DebugLog) Puts($"[上限] {uid} 的{CurName(cur)}已到上限 {Money(cap)},本次只入账 {Money(real)}"); SetBal(w, cur, val); AddEarnTotal(w, cur, real); w.TodayIn += real; _dirty = true; _txCount++; string src = string.IsNullOrEmpty(source) ? "其他" : source; AddFlow(uid, cur, real, val, src, note ?? ""); // 给以后的插件留钩子(别人可以监听"有人加钱了") var p = BasePlayer.FindByID(uid); if (p != null) Interface.CallHook("OnEcoAdd", p, cur, real, src, note ?? ""); Interface.CallHook("OnEcoBalanceChanged", uid.ToString(), cur, val, real, src); if (_config.Settings.BroadcastOnBigEarn && real >= _config.Settings.BroadcastThreshold && !string.IsNullOrEmpty(note)) PrintToChat($"[经济中心] {w.LastName} 入账 {Money(real)} {CurSymbol(cur)}({note})"); // ★ v1.0.3(用户问「采集 / 挂机的奖励聊天窗口有没有反馈」): // 采集 / 击杀 / 回收 / 在线这四样挣来的钱,原来看不到任何提示, // 只在 /bank → 收支 里躺着。现在攒够门槛就在聊天框提示一句。 if (cur == CurGold && (src == "采集" || src == "击杀" || src == "回收" || (src == "在线" && _config.Settings.OnlineInChatNotify))) EarnTip(uid, real, src); return true; } /// ★ 赚币聊天提示(v1.0.4 按用户反馈重写) /// 用户原话:「+105金币(游戏内赚的)今日合计873 明细:/bank查询 这句话要改, /// 给个人看的让他秒懂,还不能太长」「设置一个今日上限,够了之后不再弹出提示, /// 只告诉玩家距离今日收入还有多少,不影响其他的金币收入(礼包 / 商城售卖)」 /// → 文案压成一句、门槛提到 300、今日赚满就闭嘴(只在那一下说一次)。 /// ★ 只统计"玩出来"的四类(采集 / 击杀 / 回收 / 在线): /// 充值、礼包、商城售卖、管理员发放都不经这里,各自的到账提示照旧不受影响。 /// private void EarnTip(ulong uid, double real, string src) { if (!_config.Settings.EarnChatNotify || real <= 0) return; var tW = GetWallet(uid); double earnedToday = tW.TodayGather + tW.TodayKill + tW.TodayRecycle + tW.TodayOnline; bool unlim; int cnt; double dayCap = DailyEarnCap(out unlim, out cnt); double left = (unlim || cnt == 0 || dayCap <= 0) ? -1 : Math.Max(0, dayCap - earnedToday); // ---- 今日已经赚满:只在那一下说一次,之后不再提示(用户明确要求)---- if (left == 0 && _config.Settings.StopTipWhenFull) { _tipPool[uid] = 0.0; string mark; if (_tipFullDay.TryGetValue(uid, out mark) && mark == _today) return; _tipFullDay[uid] = _today; var fp = BasePlayer.FindByID(uid); if (fp != null && fp.IsConnected) SendReply(fp, $"今日已赚满 {Money(dayCap)} {_config.Settings.GoldSymbol}" + ",之后不再提示 (明天 0 点重置,金币照常发放)"); return; } // ---- 攒着,攒满门槛才发一条 ---- double pool; _tipPool.TryGetValue(uid, out pool); pool += real; double min = _config.Settings.EarnChatNotifyMin; if (min > 0 && pool < min) { _tipPool[uid] = pool; return; } var tipP = BasePlayer.FindByID(uid); if (tipP == null || !tipP.IsConnected) { _tipPool[uid] = pool; return; } // 人不在就先攒着,上线第一条补上 _tipPool[uid] = 0.0; string tail = left > 0 ? $" 还能赚 {Money(left)}" : ""; SendReply(tipP, $"{_config.Settings.GoldName} +{Money(pool)}" + $" 今日已赚 {Money(earnedToday)}" + tail); } /// ★ 扣钱(余额不足返回 false,调用方必须检查返回值) public bool Take(ulong uid, string cur, double amount, string source, string note = "") { cur = NormCur(cur); if (cur == null || uid == 0 || amount <= 0 || double.IsNaN(amount) || double.IsInfinity(amount)) return false; var w = GetWallet(uid); RollDay(w); double old = GetBal(w, cur); if (old < amount) return false; // 钱不够 —— 不扣、不记流水 double val = old - amount; SetBal(w, cur, val); AddSpentTotal(w, cur, amount); w.TodayOut += amount; _dirty = true; _txCount++; string src = string.IsNullOrEmpty(source) ? "其他" : source; AddFlow(uid, cur, -amount, val, src, note ?? ""); var p = BasePlayer.FindByID(uid); if (p != null) Interface.CallHook("OnEcoTake", p, cur, amount, src, note ?? ""); Interface.CallHook("OnEcoBalanceChanged", uid.ToString(), cur, val, -amount, src); return true; } /// 直接改余额(管理用,会记一条"管理员调整"流水) public bool SetBalance(ulong uid, string cur, double amount, string source, string note = "") { cur = NormCur(cur); if (cur == null || uid == 0 || amount < 0) return false; var w = GetWallet(uid); RollDay(w); double old = GetBal(w, cur); double cap = CapOf(cur); if (cap > 0 && amount > cap) amount = cap; if (Math.Abs(amount - old) < 0.0001) return false; SetBal(w, cur, amount); if (amount > old) AddEarnTotal(w, cur, amount - old); else AddSpentTotal(w, cur, old - amount); _dirty = true; _txCount++; string src = string.IsNullOrEmpty(source) ? "管理员" : source; AddFlow(uid, cur, amount - old, amount, src, string.IsNullOrEmpty(note) ? $"余额调整(原 {Money(old)})" : note); Interface.CallHook("OnEcoBalanceChanged", uid.ToString(), cur, amount, amount - old, src); return true; } /// 玩家互相转账(可选功能;可配抽税) public bool Transfer(ulong from, ulong to, string cur, double amount, string note = "") { cur = NormCur(cur); if (cur == null || from == 0 || to == 0 || from == to || amount <= 0) return false; var pFrom = BasePlayer.FindByID(from); var pTo = BasePlayer.FindByID(to); string nameFrom = pFrom != null ? DN(pFrom) : (GetWallet(from, false)?.LastName ?? from.ToString()); string nameTo = pTo != null ? DN(pTo) : (GetWallet(to, false)?.LastName ?? to.ToString()); if (!Take(from, cur, amount, "转账", $"转给 {nameTo}" + (string.IsNullOrEmpty(note) ? "" : " · " + note))) return false; double tax = _config.Settings.TransferTaxPercent / 100.0; double real = amount; if (tax > 0 || string.IsNullOrEmpty(note) || true) real = amount - Math.Floor(amount * tax * 100) / 100.0; if (real < 0) real = amount; Add(to, cur, real, "转账", $"来自 {nameFrom}" + (string.IsNullOrEmpty(note) ? "" : " · " + note)); Interface.CallHook("OnEcoTransfer", from.ToString(), to.ToString(), cur, amount); return true; } public bool HasEnough(ulong uid, string cur, double amount) => Balance(uid, cur) >= amount; #endregion #region ===== 流水 ===== private void AddFlow(ulong uid, string cur, double amount, double after, string source, string note) { if (Math.Abs(amount) < 0.0001) return; double min = _config.Records.MinAmount; if (min > 0 && Math.Abs(amount) < min) return; if (!_flows.TryGetValue(uid, out var f)) { f = new PlayerFlow(); _flows[uid] = f; } if (!f.Days.TryGetValue(_today, out var list)) { list = new List(); f.Days[_today] = list; } // ★★ 同类合并:同一个来源 + 同样的说明 + 还在合并窗口内 → 并进上一条,不新开一行。 // 没有这一步,挂机一小时就刷 60 条「在线挂机」(用户 2026-09-15 反馈"排行榜/流水刷屏")。 // 窗口按"上一条自己的时刻"算,所以一条流水最多覆盖一个窗口的长度,不会无限长大。 double win = _config.Records.FlowMergeSeconds; if (win > 0 && list.Count > 0) { var last = list[0]; if (last.C == cur && last.S == source && last.N == (note ?? "") && Now() - last.Ts < win) { last.A += amount; last.B = after; _dirty = true; return; } } list.Insert(0, new FlowRecord { T = Stamp(), C = cur, A = amount, B = after, S = source, N = note ?? "", Ts = Now() }); int max = Mathf.Max(20, _config.Records.MaxPerDay); if (list.Count > max) list.RemoveRange(max, list.Count - max); _dirty = true; } /// 取某人最近 N 天的流水(按时间倒序) private List GetFlows(ulong uid, string cur, int take) { var result = new List(); if (!_flows.TryGetValue(uid, out var f)) return result; foreach (var day in f.Days.Keys.OrderByDescending(x => x)) { foreach (var r in f.Days[day]) { if (cur != null && r.C != cur) continue; result.Add(r); if (result.Count >= take) return result; } } return result; } /// 取某人某一天的收支合计 private void SumDay(ulong uid, string cur, string day, out double income, out double outcome) { income = 0; outcome = 0; if (!_flows.TryGetValue(uid, out var f)) return; if (!f.Days.TryGetValue(day, out var list)) return; foreach (var r in list) { if (cur != null && r.C != cur) continue; if (r.A > 0) income += r.A; else outcome += -r.A; } } /// ★ 流水裁剪:按天整桶丢,不遍历单条记录 private void TrimFlow() { int keep = Mathf.Max(1, _config.Records.KeepDays); string cutoff = DateTime.Now.AddDays(-(keep - 1)).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); int removed = 0; foreach (var kv in _flows) { var old = kv.Value.Days.Keys.Where(d => string.CompareOrdinal(d, cutoff) < 0).ToList(); foreach (var d in old) { kv.Value.Days.Remove(d); removed++; } } if (removed > 0) _dirty = true; if (_config.Debug.DebugLog && removed > 0) Puts($"[流水] 已裁剪 {removed} 天旧记录(保留 {keep} 天)"); } #endregion #region ===== 对外 API(给以后写的插件调用)===== /* * ★ 核心约定:每次加减钱都必须带「原因」。 * 别的插件这样调(Oxide 的 Plugin.Call 走反射,public 方法即可): * var eco = plugins.Find("EcoCenter"); * eco?.Call("API_Add", player.userID, "gold", 300.0, "商城卖出 石头x300"); * bool ok = (bool)(eco?.Call("API_Take", player.userID, "gold", 600.0, "商城购买 火药x1") ?? false); */ /// 查余额 public object API_Balance(ulong uid, string cur) => Balance(uid, cur); /// 加钱,返回是否成功 public object API_Add(ulong uid, string cur, double amount, string reason) => Add(uid, cur, amount, string.IsNullOrEmpty(reason) ? "外部" : reason, reason ?? ""); /// 扣钱,余额不足返回 false(调用方必须先备货再扣钱) public object API_Take(ulong uid, string cur, double amount, string reason) => Take(uid, cur, amount, string.IsNullOrEmpty(reason) ? "外部" : reason, reason ?? ""); /// 直接设余额(管理用) public object API_Set(ulong uid, string cur, double amount, string reason) => SetBalance(uid, cur, amount, "管理员", reason ?? ""); /// 查是否够钱 public object API_Has(ulong uid, string cur, double amount) => HasEnough(uid, cur, amount); /// 查某人的今日收入 / 支出 / 余额(面板首页那三个数) public object API_Today(ulong uid) { var w = GetWallet(uid, false); if (w == null) return null; return new Dictionary { ["gold"] = w.Gold, ["diamond"] = w.Diamond, ["todayIn"] = w.TodayIn, ["todayOut"] = w.TodayOut, ["earnedGold"] = w.TotalEarnedGold, ["spentGold"] = w.TotalSpentGold, }; } /// ★ 预留给"其他插件"的接口说明(方便别人自发现能力) public object API_Version() => new Dictionary { ["name"] = "EcoCenter", ["version"] = Version.ToString(), ["currencies"] = new[] { CurGold, CurDiamond }, ["methods"] = new[] { "API_Balance", "API_Add", "API_Take", "API_Set", "API_Has", "API_Today", "API_Top" }, ["hooks"] = new[] { "OnEcoAdd", "OnEcoTake", "OnEcoBalanceChanged", "OnEcoTransfer", "OnEcoSignIn" }, ["note"] = "加减钱必须带原因,面板才能显示明细", }; /// 财富榜前 N 名 public object API_Top(string cur, int n) { cur = NormCur(cur) ?? CurGold; var list = _wallets .OrderByDescending(kv => GetBal(kv.Value, cur)) .Take(Mathf.Clamp(n, 1, 100)) .Select(kv => new Dictionary { ["steamid"] = kv.Key.ToString(), ["name"] = kv.Value.LastName, ["balance"] = GetBal(kv.Value, cur), }) .ToList(); return list; } #endregion #region ===== 赚金币:采集 / 击杀 / 回收 / 在线 ===== private bool CanEarnByDay(int limitDays, PlayerWallet w) { if (limitDays <= 0) return true; if (string.IsNullOrEmpty(w.FirstSeen)) return true; if (!DateTime.TryParse(w.FirstSeen, out var first)) return true; return (DateTime.Now - first).TotalDays <= limitDays; } // ---- 采集 ---- // ★ 签名照抄官方文档:第二个参数是 BasePlayer(不是 BaseEntity!写错则永不触发) private void OnDispenserGathered(ResourceDispenser instance, BasePlayer player, Item item) { if (!_config.Earn.Enable || !_config.Earn.EnableGather) return; if (player == null || item == null || instance == null) return; if (player.IsNpc) return; // 只算矿石/树木(玩家不想让采花采草也给钱时打开) if (_config.Earn.OnlyOreAndTree && instance.gatherType != ResourceDispenser.GatherType.Ore && instance.gatherType != ResourceDispenser.GatherType.Tree) return; var w = GetWallet(player.userID); RollDay(w); if (!CanEarnByDay(_config.Earn.OnlyfirstDays, w)) return; double cap = _config.Earn.GatherDailyCap; if (cap > 0 && w.TodayGather >= cap) return; double gain = item.amount * _config.Earn.GoldPerGatherUnit; if (VipMul(player, ref gain) == false) { } if (cap > 0) gain = Math.Min(gain, cap - w.TodayGather); if (gain <= 0) return; w.TodayGather += gain; // 说明只留物品名(不写 x 数量),同类采集才能并成一条 Add(player.userID, CurGold, gain, "采集", item.info.displayName.english); } // ---- 击杀玩家 ---- private object OnPlayerDeath(BasePlayer player, HitInfo info) { if (_config.Earn.Enable && _config.Earn.EnableKill && player != null) { var killer = info?.InitiatorPlayer; if (killer != null && killer != player && !killer.IsNpc && !player.IsNpc) { var w = GetWallet(killer.userID); RollDay(w); double cap = _config.Earn.KillDailyCap; if (CanEarnByDay(_config.Earn.OnlyfirstDays, w) && (cap <= 0 || w.TodayKill < cap)) { double gain = _config.Earn.GoldPerPlayerKill; if (IsVip(killer)) gain *= 2; if (cap > 0) gain = Math.Min(gain, cap - w.TodayKill); if (gain > 0) { w.TodayKill += gain; Add(killer.userID, CurGold, gain, "击杀", $"击杀玩家 {player.displayName}"); } } } } return null; // ★ 必须返回 null:返回非 null 会覆盖死亡逻辑 } // ---- 击杀 NPC(可选,默认关)---- private void OnEntityDeath(BaseCombatEntity entity, HitInfo info) { if (_config.Earn.GoldPerNpcKill <= 0) return; if (entity == null || entity is BasePlayer) return; // 玩家走 OnPlayerDeath,避免双份 var killer = info?.InitiatorPlayer; if (killer == null || killer.IsNpc) return; var w = GetWallet(killer.userID); RollDay(w); if (!CanEarnByDay(_config.Earn.OnlyfirstDays, w)) return; double gain = _config.Earn.GoldPerNpcKill; if (IsVip(killer)) gain *= 2; Add(killer.userID, CurGold, gain, "击杀", $"击杀 {entity.ShortPrefabName}"); } // ---- 回收:记录玩家开的是哪个回收机(OnLootEntity 频率低,能接受)---- private readonly Dictionary _recyclerOwner = new Dictionary(); private void OnLootEntity(PlayerLoot loot, BaseEntity target) { if (loot == null || target == null) return; var rec = target as Recycler; if (rec == null) return; var player = loot.GetComponentInParent(); if (player == null) return; _recyclerOwner[rec.net.ID.Value] = player.userID; } private object OnItemRecycle(Item slot, Recycler instance) { if (_config.Earn.Enable && _config.Earn.EnableRecycle && slot != null && instance != null) { ulong owner = 0; _recyclerOwner.TryGetValue(instance.net.ID.Value, out owner); if (owner == 0) owner = instance.OwnerID; // 玩家自己放的便携回收机 if (owner != 0) { var w = GetWallet(owner); RollDay(w); double cap = _config.Earn.RecycleDailyCap; if (CanEarnByDay(_config.Earn.OnlyfirstDays, w) && (cap <= 0 || w.TodayRecycle < cap)) { double gain = _config.Earn.GoldPerRecycleItem * Mathf.Max(1, slot.amount); if (cap > 0) gain = Math.Min(gain, cap - w.TodayRecycle); if (gain > 0) { w.TodayRecycle += gain; Add(owner, CurGold, gain, "回收", slot.info.displayName.english); } } } } return null; // ★ 不干预回收逻辑 } private void OnLootEntityEnd(BasePlayer player, ContainerIOEntity container) { if (container == null) return; _recyclerOwner.Remove(container.net.ID.Value); } // ---- 在线时长工资(★ 只开这一个 timer,只遍历在线玩家数组)---- private double _lastOnlineTick; private void TickOnline() { if (!_config.Earn.Enable || !_config.Earn.EnableOnline) return; double now = Now(); double delta = _lastOnlineTick <= 0 ? _config.Earn.OnlineIntervalSeconds : now - _lastOnlineTick; _lastOnlineTick = now; // 异常保护:服务器卡顿/暂停后 delta 可能很大,截断避免一次性发巨款 double maxStep = _config.Earn.OnlineIntervalSeconds * 3.0; if (delta <= 0 || delta > maxStep) delta = _config.Earn.OnlineIntervalSeconds; double perSec = _config.Earn.GoldPerOnlineMinute / 60.0; if (perSec <= 0) return; int n = 0; var list = BasePlayer.activePlayerList; // ★ 在线玩家(几十个),绝不遍历 serverEntities for (int i = 0; i < list.Count; i++) { var p = list[i]; if (p == null || !p.IsConnected) continue; if (p.IsSleeping() || p.IsDead()) continue; if (p.IsNpc) continue; var w = GetWallet(p.userID); RollDay(w); if (!CanEarnByDay(_config.Earn.OnlyfirstDays, w)) continue; double secs = delta; double cap = _config.Earn.OnlineDailyCap; if (cap > 0) { double left = cap - w.TodayOnline; if (left <= 0) continue; secs = Math.Min(secs, left / perSec); } double gain = secs * perSec; if (gain <= 0) continue; if (IsVip(p)) gain *= 2; w.TodayOnlineSeconds += secs; w.TotalOnlineSeconds += secs; w.TodayOnline += gain; // 说明故意写死(不带分钟数),这样合并窗口内会并成一条,不刷屏 Add(p.userID, CurGold, gain, "在线", "在线挂机"); n++; } if (_config.Debug.DebugLog && n > 0) Puts($"[在线工资] 本轮结算 {n} 人到账"); } /// VIP 双倍(抽出来避免散落各处的 IsVip 判断) private bool VipMul(BasePlayer p, ref double gain) { if (IsVip(p)) { gain *= 2; return true; } return false; } #endregion #region ===== 玩家进出 ===== private void OnPlayerConnected(BasePlayer player) { if (player == null || player.IsNpc) return; var w = GetWallet(player.userID); if (string.IsNullOrEmpty(w.FirstSeen)) w.FirstSeen = DateStamp(); w.LastSeen = DateStamp(); w.LastName = DN(player); RollDay(w); _dirty = true; if (_config.Settings.PopupOnConnect) timer.Once(_config.Settings.PopupDelaySeconds, () => { // ★ 这里只能是 Action(不返回值)—— 写 return true 会报 // "Anonymous function converted to a void returning delegate cannot return a value" if (player != null && player.IsConnected) ShowPanel(player, 0); }); } private void OnPlayerDisconnected(BasePlayer player, string reason) { KillUi(player); // 断线也把界面拆掉,别留在服务器端的面板状态里 if (player == null || player.IsNpc) return; var w = GetWallet(player.userID, false); if (w != null) { w.LastSeen = DateStamp(); w.LastName = DN(player); _dirty = true; } } private void OnPlayerSleepEnded(BasePlayer player) { if (player != null && !player.IsNpc) GetWallet(player.userID).LastName = DN(player); } #endregion #region ===== 维护 tick(跨天 + 裁剪)===== private string _lastMaintainDay = ""; private void TickMaintenance() { string t = Today(); if (_today != t) { _today = t; // 跨天:把所有在线玩家的"今日"清零(离线玩家下次进来 RollDay 时会清) foreach (var p in BasePlayer.activePlayerList) if (p != null && !p.IsNpc) RollDay(GetWallet(p.userID)); } if (_lastMaintainDay != t) { _lastMaintainDay = t; TrimFlow(); SaveData(); // 每天至少落盘一次,防止意外断电丢一天 if (_config.Debug.DebugLog) Puts("[维护] 跨天处理完成"); } } #endregion #region ===== 玩家命令 ===== [ChatCommand("bank")] private void CmdBank(BasePlayer player, string command, string[] args) { if (player == null) return; // ★ v1.1.0 授权入口:未授权时也必须能用(买家自救通道,不能也被软锁挡住) if (args != null && args.Length > 0 && args[0].ToLowerInvariant() == "auth") { ChatAuth(player, args); return; } ShowPanel(player, 0); } [ChatCommand("ecc")] private void CmdEcc(BasePlayer player, string command, string[] args) { if (player == null) return; if (args == null || args.Length == 0) { ShowPanel(player, 0); return; } switch (args[0].ToLowerInvariant()) { case "bank": case "银行": ShowPanel(player, 0); break; case "flow": case "收支": ShowPanel(player, 1); break; case "earn": case "赚": ShowPanel(player, 2); break; case "pay": case "充值": ShowPanel(player, 3); break; case "top": case "排行": ShowPanel(player, 4); break; case "admin": case "管理": if (!IsAdmin(player)) { SendReply(player, "你没有管理权限。"); return; } ShowPanel(player, 5); break; case "help": case "说明": ShowPanel(player, 6); break; case "auth": case "授权": ChatAuth(player, args); break; default: SendReply(player, "用法:/ecc [bank|flow|earn|pay|top|help],或直接 /bank"); break; } } [ChatCommand("gold")] private void CmdGold(BasePlayer player, string command, string[] args) { if (player == null) return; var w = GetWallet(player.userID); RollDay(w); SendReply(player, $"你的{_config.Settings.GoldName}:{Money(w.Gold)} " + $"(今日入账 {Money(w.TodayIn)},今日支出 {Money(w.TodayOut)})"); } [ChatCommand("diamond")] private void CmdDiamond(BasePlayer player, string command, string[] args) { if (player == null) return; var w = GetWallet(player.userID); SendReply(player, $"你的{_config.Settings.DiamondName}:{Money(w.Diamond)} " + $"(连签 {w.SignStreak} 天 · 累计签到 {w.SignTotal} 次)"); } [ChatCommand("pay")] private void CmdPay(BasePlayer player, string command, string[] args) { if (player == null) return; if (!EnsureAuthorized(player)) { SendReply(player, "本插件尚未授权,暂时不能转账,请联系服主。"); return; } if (!_config.Settings.AllowTransfer) { SendReply(player, "本服关闭了玩家转账。"); return; } if (args == null || args.Length < 2) { SendReply(player, "用法:/pay <玩家名> <数量> 例:/pay 小明 500"); return; } double amount; if (!double.TryParse(args[args.Length - 1], NumberStyles.Any, CultureInfo.InvariantCulture, out amount) && !double.TryParse(args[args.Length - 1], out amount)) { SendReply(player, "数量不对,例:/pay 小明 500"); return; } string targetName = string.Join(" ", args.Take(args.Length - 1).ToArray()); ulong to = ResolveUser(targetName, false); if (to == 0) { SendReply(player, $"找不到玩家「{targetName}」(必须在线的玩家才行)"); return; } if (to == player.userID) { SendReply(player, "不能转给自己。"); return; } if (amount < _config.Settings.TransferMin) { SendReply(player, $"最少转 {Money(_config.Settings.TransferMin)}。"); return; } if (!Transfer(player.userID, to, CurGold, amount)) { SendReply(player, $"转账失败:{_config.Settings.GoldName}不够(你现在 {Money(Balance(player.userID, CurGold))})。"); return; } var tp = BasePlayer.FindByID(to); SendReply(player, $"已转给 {(tp != null ? DN(tp) : targetName)} {Money(amount)} {_config.Settings.GoldSymbol}。"); if (tp != null) SendReply(tp, $"{player.displayName} 转给你 {Money(amount)} {_config.Settings.GoldSymbol}。"); } [ChatCommand("ecode")] private void CmdEcode(BasePlayer player, string command, string[] args) { if (player == null) return; if (!EnsureAuthorized(player)) { SendReply(player, "本插件尚未授权,暂时不能兑换,请联系服主。"); return; } if (args == null || args.Length == 0) { SendReply(player, "用法:/ecode <兑换码> 例:/ecode ECO-A1B2-C3D4"); SendReply(player, "兑换码从服主那里拿(扫码付款后发给你的)"); return; } string code = string.Join("", args).Trim().ToUpperInvariant(); string err = UseCode(player, code); if (err != null) { SendReply(player, err); return; } var c = _codes[code]; SendReply(player, $"兑换成功!获得 {Money(c.Diamonds)} {_config.Settings.DiamondSymbol}"); PrintToChat($"[经济中心] {player.displayName} 兑换了 {Money(c.Diamonds)} {_config.Settings.DiamondSymbol}!"); ShowPanel(player, 3); } #endregion #region ===== 控制台 / 管理命令 ===== /* * ★★ 版本差异坑(本服真机编译报过,别再改回去): * 新版 Rust 里这两样东西**不是 string**,而是 Facepunch.StringView: * · ConsoleSystem.Arg.Args 的元素 → 直接传参会报 * "Argument 1: cannot convert from 'Facepunch.StringView' to 'string'" * · BasePlayer.displayName → 直接赋值/比较/拼接同样报错 * (已在本机 Assembly-CSharp.dll 里确认 StringView 类型存在) * → 一律先过下面这两个小工具,拿到干净的 string 再随便用。 * 注意:$"...{player.displayName}..." 这种**字符串插值**是没问题的, * 插值会自动 ToString;出问题的只有"当参数传 / 当 string 赋值 / 比较"。 */ private static string[] ArgsOf(ConsoleSystem.Arg arg) { if (arg == null || arg.Args == null) return new string[0]; var a = new string[arg.Args.Length]; for (int i = 0; i < a.Length; i++) a[i] = arg.Args[i].ToString(); return a; } /// 玩家名 → string(displayName 是 StringView,不能直接当 string 用) private static string DN(BasePlayer p) => p == null ? "" : p.displayName.ToString(); [ConsoleCommand("ecocenter.give")] private void CcGive(ConsoleSystem.Arg arg) { if (!CheckAdmin(arg)) return; if (!AuthGuardConsole(arg, "给玩家发放")) return; var a = ArgsOf(arg); if (a.Length < 3) { Reply(arg, "用法:ecocenter.give <数量> [说明]"); return; } ulong uid = ResolveUser(a[0], true); if (uid == 0) { Reply(arg, $"找不到玩家:{a[0]}"); return; } string cur = NormCur(a[1]); if (cur == null) { Reply(arg, "币种只能是 gold 或 diamond(也认 金币/钻石)"); return; } double amount; if (!double.TryParse(a[2], out amount) || amount <= 0) { Reply(arg, "数量不对"); return; } string note = a.Length > 3 ? string.Join(" ", a.Skip(3).ToArray()) : "管理员发放"; if (Add(uid, cur, amount, "管理员", note)) Reply(arg, $"已给 {NameOf(uid)} 加 {Money(amount)} {CurSymbol(cur)}({note})。当前余额 {Money(Balance(uid, cur))}"); else Reply(arg, "发放失败(可能已到上限)"); } [ConsoleCommand("ecocenter.take")] private void CcTake(ConsoleSystem.Arg arg) { if (!CheckAdmin(arg)) return; var a = ArgsOf(arg); if (a.Length < 3) { Reply(arg, "用法:ecocenter.take <数量> [说明]"); return; } ulong uid = ResolveUser(a[0], true); if (uid == 0) { Reply(arg, $"找不到玩家:{a[0]}"); return; } string cur = NormCur(a[1]); if (cur == null) { Reply(arg, "币种只能是 gold 或 diamond"); return; } double amount; if (!double.TryParse(a[2], out amount) || amount <= 0) { Reply(arg, "数量不对"); return; } string note = a.Length > 3 ? string.Join(" ", a.Skip(3).ToArray()) : "管理员扣除"; if (Take(uid, cur, amount, "管理员", note)) Reply(arg, $"已扣 {NameOf(uid)} {Money(amount)} {CurSymbol(cur)}。当前余额 {Money(Balance(uid, cur))}"); else Reply(arg, $"扣除失败:余额不足(当前 {Money(Balance(uid, cur))})"); } [ConsoleCommand("ecocenter.set")] private void CcSet(ConsoleSystem.Arg arg) { if (!CheckAdmin(arg)) return; if (!AuthGuardConsole(arg, "修改玩家余额")) return; var a = ArgsOf(arg); if (a.Length < 3) { Reply(arg, "用法:ecocenter.set <数量> [说明]"); return; } ulong uid = ResolveUser(a[0], true); if (uid == 0) { Reply(arg, $"找不到玩家:{a[0]}"); return; } string cur = NormCur(a[1]); if (cur == null) { Reply(arg, "币种只能是 gold 或 diamond"); return; } double amount; if (!double.TryParse(a[2], out amount) || amount < 0) { Reply(arg, "数量不对"); return; } string note = a.Length > 3 ? string.Join(" ", a.Skip(3).ToArray()) : ""; if (SetBalance(uid, cur, amount, "管理员", note)) Reply(arg, $"{NameOf(uid)} 的{CurName(cur)}已设为 {Money(amount)}"); else Reply(arg, "余额没变(或数值非法)"); } // ★★★ 商户接口预留:所有充值到账都走这一个入口(手动发放 / 兑换码 / 以后的微信回调) [ConsoleCommand("ecocenter.recharge")] private void CcRecharge(ConsoleSystem.Arg arg) { if (!CheckAdmin(arg)) return; if (!AuthGuardConsole(arg, "处理充值到账")) return; var a = ArgsOf(arg); if (a.Length < 3) { Reply(arg, "用法:ecocenter.recharge <订单号> <钻石数> [备注]"); Reply(arg, "★ 以后微信支付回调就调这条命令,插件不用改"); return; } string orderId = a[0].Trim(); ulong uid = ResolveUser(a[1], true); if (uid == 0) { Reply(arg, $"找不到玩家:{a[1]}"); return; } double dia; if (!double.TryParse(a[2], out dia) || dia <= 0) { Reply(arg, "钻石数不对"); return; } string note = a.Length > 3 ? string.Join(" ", a.Skip(3).ToArray()) : "服主发放"; string channel = _config.Merchant.EnableMerchantApi ? "wechat" : "manual"; string err = RechargeByOrder(orderId, uid, dia, channel, note); if (err != null) { Reply(arg, err); return; } Reply(arg, $"✅ 订单 {orderId} 已发放:{NameOf(uid)} +{Money(dia)} {_config.Settings.DiamondSymbol},备注「{note}」"); } [ConsoleCommand("ecocenter.order")] private void CcOrder(ConsoleSystem.Arg arg) { if (!CheckAdmin(arg)) return; var a = ArgsOf(arg); if (a.Length < 1) { Reply(arg, "用法:ecocenter.order <订单号>"); return; } string id = a[0].Trim(); if (!_orders.TryGetValue(id, out var o)) { Reply(arg, $"没有订单 {id}"); return; } Reply(arg, $"订单 {o.Id} | {o.Name}({o.Uid}) | {Money(o.Diamonds)} 钻石 | 渠道 {o.Channel} " + $"| 状态 {o.Status} | {o.Time} | {o.Note}"); } // ★ 批量生成兑换码(服主收款后把码发给玩家,玩家 /ecode 领取) [ConsoleCommand("ecocenter.code")] private void CcCode(ConsoleSystem.Arg arg) { if (!CheckAdmin(arg)) return; if (!AuthGuardConsole(arg, "生成兑换码")) return; var a = ArgsOf(arg); if (a.Length < 1) { Reply(arg, "用法:ecocenter.code <钻石数> [张数] [人民币] [备注]"); Reply(arg, "例:ecocenter.code 100 10 10 → 生成 10 张「100 钻石 = 10 元」的码"); return; } double dia; if (!double.TryParse(a[0], out dia) || dia <= 0) { Reply(arg, "钻石数不对"); return; } int count = 1; if (a.Length > 1) int.TryParse(a[1], out count); count = Mathf.Clamp(count, 1, 200); double rmb = 0; if (a.Length > 2) double.TryParse(a[2], out rmb); string note = a.Length > 3 ? string.Join(" ", a.Skip(3).ToArray()) : ""; var made = new List(); for (int i = 0; i < count; i++) made.Add(GenCode(dia, rmb, note)); Reply(arg, $"已生成 {made.Count} 张兑换码({Money(dia)} 钻石/张):"); foreach (var c in made) Reply(arg, " " + c); Reply(arg, "★ 把码发给付款的玩家,他在聊天框输入 /ecode <码> 即可领取"); } [ConsoleCommand("ecocenter.export")] private void CcExport(ConsoleSystem.Arg arg) { if (!CheckAdmin(arg)) return; var a = ArgsOf(arg); string cur = NormCur(a.Length > 0 ? a[0] : "") ?? CurGold; try { var sb = new StringBuilder(); sb.AppendLine("steamid,玩家名," + CurName(cur) + ",累计赚,累计花,最后在线"); foreach (var kv in _wallets.OrderByDescending(x => GetBal(x.Value, cur))) { sb.AppendLine($"{kv.Key},{Esc(kv.Value.LastName)},{GetBal(kv.Value, cur):0.##}," + $"{kv.Value.TotalEarnedGold:0.##},{kv.Value.TotalSpentGold:0.##},{kv.Value.LastSeen}"); } // ★ 别用 DataFileSystem.WriteFile —— 本服 Oxide 里没有这个方法(真机编译报过) // 直接写文件最稳 string dir = Path.Combine(Interface.Oxide.DataDirectory, DataDir); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); string name = $"export_{cur}_{DateTime.Now:yyyyMMdd_HHmmss}.csv"; string full = Path.Combine(dir, name); File.WriteAllText(full, sb.ToString(), new UTF8Encoding(true)); Reply(arg, "已导出:" + full); } catch (Exception e) { Reply(arg, "导出失败:" + e.Message); } } private static string Esc(string s) => string.IsNullOrEmpty(s) ? "" : s.Replace(",", ","); [ConsoleCommand("ecocenter.stats")] private void CcStats(ConsoleSystem.Arg arg) { if (!CheckAdmin(arg)) return; double gSum = 0, dSum = 0, gEarn = 0, gSpent = 0, dEarn = 0, dSpent = 0; foreach (var kv in _wallets) { gSum += kv.Value.Gold; dSum += kv.Value.Diamond; gEarn += kv.Value.TotalEarnedGold; gSpent += kv.Value.TotalSpentGold; dEarn += kv.Value.TotalEarnedDiamond; dSpent += kv.Value.TotalSpentDiamond; } Reply(arg, "========== 经济中心 · 全服报表 =========="); Reply(arg, $"钱包数 {_wallets.Count} | 在线 {BasePlayer.activePlayerList.Count} 人"); Reply(arg, $"金币:存量 {Money(gSum)} | 累计产出 {Money(gEarn)} | 累计消耗 {Money(gSpent)}"); Reply(arg, $"钻石:存量 {Money(dSum)} | 累计发放 {Money(dEarn)} | 累计消耗 {Money(dSpent)}"); Reply(arg, $"订单 {_orders.Count} 笔 | 未用兑换码 {_codes.Count(c => c.Value.UsedBy == 0)} 张"); Reply(arg, $"写盘 {_saveCount} 次 | 交易 {_txCount} 笔 | 流水覆盖 {_flows.Count} 人 | 保留 {_config.Records.KeepDays} 天"); var topG = _wallets.OrderByDescending(x => x.Value.Gold).Take(5).ToList(); if (topG.Count > 0) { Reply(arg, "--- 金币榜前 5 ---"); for (int i = 0; i < topG.Count; i++) Reply(arg, $" {i + 1}. {topG[i].Value.LastName} {Money(topG[i].Value.Gold)}"); } var topD = _wallets.OrderByDescending(x => x.Value.Diamond).Take(5).ToList(); if (topD.Count > 0) { Reply(arg, "--- 钻石榜前 5 ---"); for (int i = 0; i < topD.Count; i++) Reply(arg, $" {i + 1}. {topD[i].Value.LastName} {Money(topD[i].Value.Diamond)}"); } } // ★ 性能自检:买家也能自己敲,看我们有没有吹牛 [ConsoleCommand("ecocenter.diag")] private void CcDiag(ConsoleSystem.Arg arg) { Reply(arg, "========== 经济中心 · 性能自检 =========="); Reply(arg, "① 有没有挂 OnPlayerTick? ❌ 没有(本插件完全没有这个方法)"); Reply(arg, "② 写盘策略?    ✅ 只在【服务器自动存档】和【插件卸载】时写,绝不每笔交易写"); Reply(arg, $"③ 本次运行写盘次数:{_saveCount} 次 交易笔数:{_txCount} 笔"); Reply(arg, "④ 高频钩子里遍历全服实体? ❌ 没有(只用在线玩家数组,几十个元素)"); Reply(arg, $"⑤ 定时器:2 个(在线工资 {_config.Earn.OnlineIntervalSeconds:0}s + 维护 300s),没有其它轮询"); Reply(arg, $"⑥ 内存数据:{_wallets.Count} 个钱包 / {_flows.Count} 份流水 / {_orders.Count} 订单 / {_codes.Count} 兑换码"); Reply(arg, $"⑦ 流水裁剪:保留 {_config.Records.KeepDays} 天,每天最多 {_config.Records.MaxPerDay} 条/人"); Reply(arg, "⑧ 外部依赖:无(不需要 Economics / ServerRewards / 任何第三方经济插件)"); Reply(arg, "⑨ 最近一次面板点击:" + _lastUiClick); } private bool CheckAdmin(ConsoleSystem.Arg arg) { if (arg == null) return false; var p = arg.Player(); if (p != null && !IsAdmin(p)) { Reply(arg, "你没有权限(需要 " + PermAdmin + ")"); return false; } return true; // 控制台(无玩家)= 允许 } private void Reply(ConsoleSystem.Arg arg, string msg) { if (arg == null) { Puts(msg); return; } var p = arg.Player(); if (p != null) SendReply(p, msg); else Puts(msg); } private string NameOf(ulong uid) { var p = BasePlayer.FindByID(uid); if (p != null) return DN(p); var w = GetWallet(uid, false); return string.IsNullOrEmpty(w?.LastName) ? uid.ToString() : $"{w.LastName}({uid})"; } /// 把玩家名 / steamid 解析成 userID;online_only=false 时也会在钱包里找历史玩家 private ulong ResolveUser(string s, bool searchData) { if (string.IsNullOrEmpty(s)) return 0; s = s.Trim().Trim('"'); ulong id; if (ulong.TryParse(s, out id) && id > 76561190000000000UL) return id; var exact = BasePlayer.activePlayerList.FirstOrDefault(p => string.Equals(DN(p), s, StringComparison.OrdinalIgnoreCase)); if (exact != null) return exact.userID; var part = BasePlayer.activePlayerList.FirstOrDefault(p => DN(p).IndexOf(s, StringComparison.OrdinalIgnoreCase) >= 0); if (part != null) return part.userID; if (searchData) { var hit = _wallets.FirstOrDefault(kv => !string.IsNullOrEmpty(kv.Value.LastName) && string.Equals(kv.Value.LastName, s, StringComparison.OrdinalIgnoreCase)); if (hit.Key != 0) return hit.Key; hit = _wallets.FirstOrDefault(kv => !string.IsNullOrEmpty(kv.Value.LastName) && kv.Value.LastName.IndexOf(s, StringComparison.OrdinalIgnoreCase) >= 0); if (hit.Key != 0) return hit.Key; } return 0; } #endregion #region ===== 充值 / 订单(★ 商户接口预留)===== /// /// ★★★ 所有充值到账的唯一入口。订单号幂等 —— 同一个订单号只发一次,防重放。 /// 以后微信支付回调 / 其他平台回调用途:RCON 调 ecocenter.recharge 即可,插件不用改。 /// private string RechargeByOrder(string orderId, ulong uid, double diamonds, string channel, string note) { if (string.IsNullOrEmpty(orderId)) return "订单号不能为空"; if (uid == 0) return "玩家不存在"; if (diamonds <= 0) return "钻石数必须大于 0"; if (_orders.TryGetValue(orderId, out var exist)) { if (exist.Status == "done" && !_config.Merchant.AllowOverwriteOrder) return $"❌ 订单 {orderId} 已经发放过了({exist.Time} 给了 {Money(exist.Diamonds)} 钻石),不重复发放"; } if (!Add(uid, CurDiamond, diamonds, string.IsNullOrEmpty(channel) ? "微信充值" : ChannelName(channel), string.IsNullOrEmpty(note) ? "充值到账" : note)) return "发放失败(可能已到钻石上限)"; _orders[orderId] = new RechargeOrder { Id = orderId, Uid = uid, Name = NameOf(uid), Diamonds = diamonds, Channel = channel, Status = "done", Time = DateStamp(), Note = note ?? "", }; _ordersDirty = true; var p = BasePlayer.FindByID(uid); if (p != null) SendReply(p, $"充值到账:+{Money(diamonds)} {_config.Settings.DiamondSymbol}" + (string.IsNullOrEmpty(note) ? "" : $"({note})")); Interface.CallHook("OnEcoRecharge", uid.ToString(), orderId, diamonds, channel); return null; } private static string ChannelName(string ch) { switch ((ch ?? "").ToLowerInvariant()) { case "manual": return "服主发放"; case "wechat": return "微信充值"; case "code": return "兑换码"; case "gift": return "赠送"; default: return string.IsNullOrEmpty(ch) ? "充值" : ch; } } #endregion #region ===== 兑换码 ===== private static readonly char[] CodeChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".ToCharArray(); private string GenCode(double diamonds, double rmb, string note) { string code = ""; for (int attempt = 0; attempt < 50; attempt++) { var sb = new StringBuilder(); sb.Append(string.IsNullOrEmpty(_config.Recharge.CodePrefix) ? "ECO" : _config.Recharge.CodePrefix); sb.Append('-'); for (int i = 0; i < 4; i++) sb.Append(CodeChars[UnityEngine.Random.Range(0, CodeChars.Length)]); sb.Append('-'); for (int i = 0; i < 4; i++) sb.Append(CodeChars[UnityEngine.Random.Range(0, CodeChars.Length)]); code = sb.ToString(); if (!_codes.ContainsKey(code)) break; } var c = new RedeemCode { Code = code, Diamonds = diamonds, Rmb = rmb, Created = DateStamp(), Note = note ?? "", }; int days = _config.Recharge.CodeExpireDays; c.Expire = days > 0 ? DateTime.Now.AddDays(days).ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) : ""; _codes[code] = c; _ordersDirty = true; return code; } /// 使用兑换码;成功返回 null,失败返回错误文案 private string UseCode(BasePlayer player, string code) { if (player == null) return "玩家不存在"; if (string.IsNullOrEmpty(code)) return "兑换码不能为空"; RedeemCode c; if (!_codes.TryGetValue(code, out c)) return "❌ 兑换码不存在,检查一下有没有输错(区分中划线)"; if (c.UsedBy != 0) return $"❌ 这张码已经被用过了({c.UsedAt})"; if (!string.IsNullOrEmpty(c.Expire) && DateTime.TryParse(c.Expire, out var exp) && DateTime.Now > exp) return $"❌ 这张码已过期({c.Expire})"; if (c.Diamonds <= 0) return "❌ 这张码没有钻石"; if (!Add(player.userID, CurDiamond, c.Diamonds, "兑换码", $"兑换码 {code}")) return "❌ 兑换失败(可能已到钻石上限)"; c.UsedBy = player.userID; c.UsedAt = DateStamp(); _ordersDirty = true; _orders["CODE-" + code] = new RechargeOrder { Id = "CODE-" + code, Uid = player.userID, Name = DN(player), Diamonds = c.Diamonds, Rmb = c.Rmb, Channel = "code", Status = "done", Time = DateStamp(), Note = "兑换码领取", }; Interface.CallHook("OnEcoRedeem", player, code, c.Diamonds); return null; } #endregion #region ===== 签到 ===== private double SignRewardOf(int streak) { var list = _config.SignIn.Rewards; if (list == null || list.Count == 0) return 0; int idx = (Mathf.Max(1, streak) - 1) % list.Count; return list[idx].Diamonds; } private string SignNote(int streak) { var list = _config.SignIn.Rewards; if (list == null || list.Count == 0) return $"连签第 {streak} 天"; int idx = (Mathf.Max(1, streak) - 1) % list.Count; var r = list[idx]; return string.IsNullOrEmpty(r.Note) ? $"连签第 {streak} 天" : $"连签第 {streak} 天 · {r.Note}"; } /// 签到;成功返回 null,失败返回错误文案 private string ClaimSignIn(BasePlayer player) { if (!_config.SignIn.Enable) return "签到功能当前已关闭"; if (player == null) return "玩家不存在"; var w = GetWallet(player.userID); RollDay(w); string today = _today; if (_config.SignIn.OncePerDay && w.LastSignDate == today) return "今天已经签过了,明天再来!"; int next; if (w.LastSignDate == Yesterday()) next = w.SignStreak + 1; else next = _config.SignIn.ResetOnMiss ? 1 : w.SignStreak + 1; if (next < 1) next = 1; double amount = SignRewardOf(next); if (IsVip(player)) amount *= _config.SignIn.VipMultiplier; if (amount <= 0) return "签到奖励没配置(让服主检查 config 的「签到 SignIn」)"; w.SignStreak = next; w.SignTotal++; if (next > w.BestStreak) w.BestStreak = next; w.LastSignDate = today; _dirty = true; Add(player.userID, CurDiamond, amount, "签到", SignNote(next)); if (_config.SignIn.BroadcastOnClaim) PrintToChat(_config.SignIn.BroadcastMessage .Replace("{player}", DN(player)) .Replace("{day}", next.ToString()) .Replace("{amount}", Money(amount))); Interface.CallHook("OnEcoSignIn", player, next, amount); return null; } #endregion #region ===== 面板:CUI 骨架 ===== /* * ★ 三条真机踩坑记录(照抄 GiftCenter 已验证的写法,别再改回去): * 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) * 3) 元素 Name 是客户端 GameObject 字典的键,**同一容器内必须唯一** * → 图片名统一 ecocenter.img.<自增>,卡片名统一 ecocenter.card.<自增> * * ★ 坐标系:左下角为原点,y 越大越靠上。 * 所以"第一行"要排最高的 y,往下递减 —— 写反了首行会贴底(真机踩过)。 */ // 面板状态:每个玩家一份,都很小(关面板不清,重开保留上次页签/筛选) private readonly Dictionary _openTab = new Dictionary(); private readonly Dictionary _flowPage = new Dictionary(); private readonly Dictionary _flowCur = new Dictionary(); private readonly Dictionary _topCur = new Dictionary(); private readonly Dictionary _topPage = new Dictionary(); // ★ 排行榜翻页(人多了必须翻) private readonly Dictionary _admPage = new Dictionary(); // ★ v1.0.2 管理后台「在线玩家」翻页 private readonly Dictionary _tipPool = new Dictionary(); // ★ v1.0.3 赚币聊天提示的累计池 private readonly Dictionary _tipFullDay = new Dictionary(); // ★ v1.0.4 「今日赚满」已提示过的日期(每天只念叨一次) // 页签索引(与左菜单顺序一致;普通玩家看不到「管理后台」) private const int TBank = 0, TFlow = 1, TEarn = 2, TPay = 3, TRank = 4, TAdm = 5, THelp = 6; private const int TCount = 7; private static readonly string[] TabNames = { "银 行", "收 支", "赚金币", "充值签到", "财富排行", "管理后台", "使用说明" }; // 菜单排版 private const float MenuTop = 494f; // 第一个菜单项的「上边」y(往下递减;7 项 × 43 = 底到 198,钱包卡在 188 以下) private const float MenuTitleY0 = 524f, MenuTitleY1 = 554f; // 元素名自增(同一容器内唯一) private static int _uiSeq; private static string NewName(string prefix) => prefix + "." + (++_uiSeq); // 最近一次面板点击(排查"点了没反应"用:ecocenter.diag 第 ⑨ 行会打出来) private static string _lastUiClick = "(本次运行还没有人点过面板按钮)"; // ★ ImageLibrary 可选依赖:没有它也能跑(素材图走游戏内置贴图兜底,收款码位置显示文字提示) [PluginReference] private Plugin ImageLibrary; // ---- CUI 色值 → 聊天框 ---- private static string HexOf(string cui) { if (string.IsNullOrEmpty(cui)) return "FFFFFF"; var parts = cui.Trim().Split(' '); if (parts.Length < 3) return "FFFFFF"; var sb = new StringBuilder(6); for (int i = 0; i < 3; i++) { float f; if (!float.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out f)) return "FFFFFF"; int v = Mathf.Clamp(Mathf.RoundToInt(f * 255f), 0, 255); sb.Append(v.ToString("X2", CultureInfo.InvariantCulture)); } return sb.ToString(); } // ---- ★ 四套配色预设(顺序与 PresetKeys 一一对应,改顺序要同时改 PresetKeys)---- private static readonly string[] PresetKeys = { "root", "card", "cardsoft", "line", "banner", "accent", "accentbg", "onaccent", "text", "textdim" }; private static readonly Dictionary ThemePresets = new Dictionary { // root / card / cardsoft / line / banner / accent / accentbg / onaccent / text / textdim { "graphite", new[] { "0.055 0.059 0.066 0.985", "0.106 0.114 0.125 0.96", "0.082 0.090 0.102 0.96", "0.208 0.220 0.243 1", "0.078 0.086 0.098 0.98", "0.949 0.780 0.376 1", "0.345 0.267 0.086 0.98", "1 0.953 0.843 1", "0.945 0.937 0.914 1", "0.635 0.647 0.667 1" } }, { "arsenal", new[] { "0.051 0.071 0.063 0.98", "0.157 0.196 0.173 0.90", "0.133 0.169 0.145 0.92", "0.227 0.278 0.243 1", "0.094 0.125 0.098 0.96", "0.831 0.698 0.353 1", "0.557 0.459 0.188 0.95", "1 0.965 0.847 1", "0.902 0.894 0.839 1", "0.588 0.627 0.561 1" } }, { "wasteland", new[] { "0.071 0.055 0.047 0.985", "0.145 0.114 0.094 0.96", "0.110 0.086 0.071 0.96", "0.278 0.220 0.180 1", "0.098 0.075 0.063 0.98", "0.941 0.600 0.290 1", "0.400 0.216 0.078 0.98", "1 0.941 0.859 1", "0.953 0.925 0.890 1", "0.678 0.616 0.561 1" } }, { "ocean", new[] { "0.043 0.055 0.075 0.985", "0.090 0.118 0.157 0.96", "0.067 0.090 0.122 0.96", "0.169 0.220 0.290 1", "0.059 0.078 0.106 0.98", "0.376 0.827 0.910 1", "0.086 0.298 0.365 0.98", "0.902 0.980 1 1", "0.929 0.945 0.965 1", "0.596 0.655 0.718 1" } }, }; private static bool IsCuiColor(string v) { if (string.IsNullOrEmpty(v)) return false; var parts = v.Trim().Split(' '); if (parts.Length < 3) return false; for (int i = 0; i < 3; i++) { float f; if (!float.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out f)) return false; if (f < 0f || f > 1f) return false; } return true; } private string PresetColor(string key) { string preset = _config?.Theme?.Preset; if (string.IsNullOrEmpty(preset)) preset = "graphite"; preset = preset.Trim().ToLowerInvariant(); string[] arr; if (!ThemePresets.TryGetValue(preset, out arr)) arr = ThemePresets["graphite"]; int i = Array.IndexOf(PresetKeys, key); return (i < 0 || i >= arr.Length) ? null : arr[i]; } /// 主题取色:config 单项覆盖 → 预设 → 内置默认。填错一律回落,绝不把面板搞崩 private string Th(string key, string def) { var t = _config == null ? null : _config.Theme; string v = null; if (t != null) { switch (key) { case "accent": v = t.Accent; break; case "root": v = t.Root; break; case "card": v = t.Card; break; case "cardsoft": v = t.CardSoft; break; case "accentbg": v = t.AccentBg; break; case "text": v = t.Text; break; case "textdim": v = t.TextDim; break; case "line": v = t.Line; break; } } if (string.IsNullOrEmpty(v)) v = PresetColor(key); if (string.IsNullOrEmpty(v)) return def; return IsCuiColor(v) ? v.Trim() : def; } 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; } // ---- 图片通路自动分流(四条;放错字段不报错,只在客户端留 [FileSystem] Not Found + 白块)---- 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)); 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 = NewName("ecocenter.img") }; 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; } 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); } // 物品图标:客户端本地图标,不碰图片库、不碰网络,最稳 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); } // 纯色块(卡片 / 压暗层 / 描边)—— 组件的父级只能通过 container.Add(component, parent, name) 传 private void Veil(CuiElementContainer c, string parent, float x0, float y0, float x1, float y1, string color, float s, string name = null) { c.Add(MkPanel(x0, y0, x1, y1, color, s), parent, name ?? NewName("ecocenter.card")); } // 卡片:底 + 四边 1px 描边(有轮廓才显得精致,只有一条上边线看着像没画完) private void Card(CuiElementContainer c, string parent, float x0, float y0, float x1, float y1, float s) { string line = Th("line", ColLine); Veil(c, parent, x0, y0, x1, y1, Th("card", ColCard), s); Veil(c, parent, x0, y1 - 1f, x1, y1, line, s); Veil(c, parent, x0, y0, x1, y0 + 1f, line, s); Veil(c, parent, x0, y0, x0 + 1f, y1, line, s); Veil(c, parent, x1 - 1f, y0, x1, y1, line, s); } // 进度条(每日上限用):底条 + 按百分比的填充条 private void Bar(CuiElementContainer c, string parent, float x0, float y0, float x1, float y1, double pct, string fg, float s) { Veil(c, parent, x0, y0, x1, y1, ColCardSoft, s); double p = Math.Max(0, Math.Min(1, pct)); float fill = x0 + (float)((x1 - x0) * p); if (fill - x0 < 1f) fill = x0 + 1f; Veil(c, parent, x0, y0, fill, y1, fg, s); } // ---- 素材图(oxide/data/EcoCenter/assets/*.png → ImageLibrary → CUI 用的图 id)---- private string AssetDirPath() => Path.Combine(Interface.Oxide.DataDirectory, DataDir, "assets"); // ImageLibrary 的键名:文件 → "ecocenter.<去掉扩展名的文件名>" private static string KeyOf(string fileName) { if (string.IsNullOrEmpty(fileName)) return null; var name = fileName; int slash = Math.Max(name.LastIndexOf('/'), name.LastIndexOf('\\')); if (slash >= 0) name = name.Substring(slash + 1); int dot = name.LastIndexOf('.'); if (dot > 0) name = name.Substring(0, dot); return "ecocenter." + name; } // 装饰图(放不放都行,不放 = 纯色 + 游戏内置贴图) private static readonly string[] DecoFiles = { "bg-bank.png", "corner-tl.png", "corner-tr.png", "corner-bl.png", "corner-br.png", "coin.png", "diamond.png" }; private List AllAssetFiles() { var list = new List(); if (_config?.Recharge != null && !string.IsNullOrEmpty(_config.Recharge.WechatQrImage)) list.Add(_config.Recharge.WechatQrImage); list.AddRange(DecoFiles); return list.Where(x => !string.IsNullOrEmpty(x)).Distinct().ToList(); } // ★ GetImage 签名 = (string imageName, ulong imageId = 0, bool returnUrl = false) // 第三个参数是 bool,别把 userID 传进去(LoginRewards 旧版踩过) 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 AssetSprite(string fileName) => string.IsNullOrEmpty(fileName) ? null : GetSprite(KeyOf(fileName)); private int _regAttempts; // ★ AddImageData 签名 = (string imageName, byte[] array, ulong imageId) // 自带字节这条路不碰 http/https、不需要证书、没有 3 MB 上限 —— 卖家包能离线用 private void RegisterImages() { if (ImageLibrary == null) return; // 没装图片库就算了,面板照常能用 try { if (!Convert.ToBoolean(ImageLibrary.Call("IsReady"))) { if (_regAttempts++ < 30) timer.Once(5f, RegisterImages); // 图片库加载比本插件晚,等它 return; } } catch { return; } int ok = 0, miss = 0; var missing = new List(); foreach (var file in AllAssetFiles()) { var key = KeyOf(file); if (string.IsNullOrEmpty(key)) continue; try { var path = Path.Combine(AssetDirPath(), file); if (!File.Exists(path)) { miss++; missing.Add(file); continue; } var bytes = File.ReadAllBytes(path); if (bytes == null || bytes.Length < 8) { miss++; continue; } ImageLibrary.Call("AddImageData", key, bytes, 0UL); ok++; } catch (Exception ex) { miss++; PrintWarning($"素材图读取失败 {file}:{ex.Message}"); } } Puts($"素材图已注册 {ok} 张" + (miss > 0 ? $"(缺 {miss} 张,缺的会退回纯色文字,不影响功能)" : "")); if (miss > 0) Puts(" 素材文件夹:" + AssetDirPath() + " 缺:" + string.Join("、", missing.Take(10).ToArray()) + " → 放进去后执行 oxide.reload EcoCenter"); } // ---- 排行榜辅助 ---- private List> TopList(string cur, int n) => _wallets.OrderByDescending(kv => GetBal(kv.Value, cur)).Take(n).ToList(); private int MyRank(ulong uid, string cur) { double mine = Balance(uid, cur); int rank = 1; foreach (var kv in _wallets) if (GetBal(kv.Value, cur) > mine) rank++; return rank; } private static string ShortName(string name, int max) { if (string.IsNullOrEmpty(name)) return "(无名)"; return name.Length <= max ? name : name.Substring(0, max) + "…"; } // ---- 开关面板 ---- private void ClosePanel(BasePlayer player) { if (player == null || !player.IsConnected) return; CuiHelper.DestroyUi(player, PanelName); } /// ★ 面板总入口。tab = TBank..THelp;普通玩家点管理后台会被弹回银行页。 private void ShowPanel(BasePlayer player, int tab) { if (player == null || !player.IsConnected) return; // ★ v1.1.0 软锁:未授权时管理员看到激活说明页、普通玩家静默拒绝 if (!EnsureAuthorized(player)) return; if (tab == TAdm && !IsAdmin(player)) tab = TBank; if (tab < 0 || tab >= TCount) tab = TBank; _openTab[player.userID] = tab; CuiHelper.DestroyUi(player, PanelName); float s = Scale(); float W = BaseW * s, H = BaseH * s; var c = new CuiElementContainer(); // ---------- 主面板(屏幕正中)---------- var root = new CuiPanel(); root.Image.Color = Th("root", ColRoot); 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("bg-bank.png"), 0, 0, BaseW, BaseH, "1 1 1 0.90", s, SpBgTile); Veil(c, PanelName, 0, 0, BaseW, BaseH, ColVeil, s, PanelName + ".veil"); // ---------- 四角铆钉(可选装饰,64x64,1:1 不缩放)---------- Img(c, PanelName, AssetSprite("corner-tl.png"), 0, BaseH - CornerSize, CornerSize, BaseH, "1 1 1 0.95", s); Img(c, PanelName, AssetSprite("corner-tr.png"), BaseW - CornerSize, BaseH - CornerSize, BaseW, BaseH, "1 1 1 0.95", s); Img(c, PanelName, AssetSprite("corner-bl.png"), 0, 0, CornerSize, CornerSize, "1 1 1 0.95", s); Img(c, PanelName, AssetSprite("corner-br.png"), BaseW - CornerSize, 0, BaseW, CornerSize, "1 1 1 0.95", s); var w = GetWallet(player.userID); BuildMenu(c, player, w, tab, s); BuildBanner(c, player, w, tab, s); // ---------- 内容区底板 ---------- Veil(c, PanelName, BodyX0, BodyY0, BodyX1, BodyY1, "0.035 0.040 0.047 0.72", s, PanelName + ".body"); switch (tab) { case TBank: BuildBank(c, player, w, s); break; case TFlow: BuildFlow(c, player, w, s); break; case TEarn: BuildEarn(c, player, w, s); break; case TPay: BuildRecharge(c, player, w, s); break; case TRank: BuildTop(c, player, w, s); break; case TAdm: BuildAdmin(c, player, w, s); break; default: BuildHelp(c, player, w, s); break; } CuiHelper.AddUi(player, c); } // 页脚提示条(所有页签统一走它,位置和字号不会各写各的) private void Foot(CuiElementContainer c, string text, float s) { if (string.IsNullOrEmpty(text)) return; c.Add(MkLabel(text, BodyX0 + 4f, FootY0, BodyX1 - 4f, FootY1, 11, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); } // ---------------- 左菜单 ---------------- private void BuildMenu(CuiElementContainer c, BasePlayer player, PlayerWallet w, int tab, float s) { Veil(c, PanelName, MenuX0, MenuY0, MenuX1, MenuY1, Th("cardsoft", ColCardSoft), s, PanelName + ".menu"); Veil(c, PanelName, MenuX1 - 1f, MenuY0, MenuX1, MenuY1, Th("line", ColLine), s); c.Add(MkLabel("经 济 中 心", MenuX0, MenuTitleY0 + 2f, MenuX1, MenuTitleY1, 16, TextAnchor.MiddleCenter, Th("accent", ColAccent), s), PanelName); Veil(c, PanelName, MenuX0 + 44f, MenuTitleY0 - 5f, MenuX1 - 44f, MenuTitleY0 - 4f, Th("accent", ColAccent), s); // ★ v1.0.2 用户截图反馈:这行英文原来在 y 500~516,而第一个菜单项 MenuTop 也是 516 // → 正好压在「银 行」按钮上。现在压到标题下划线之下、菜单项之上(499~512)。 c.Add(MkLabel("E C O C E N T E R", MenuX0, MenuTitleY0 - 25f, MenuX1, MenuTitleY0 - 12f, 9, TextAnchor.MiddleCenter, ColTextDim, s), PanelName); for (int i = 0; i < TCount; i++) { if (i == TAdm && !IsAdmin(player)) continue; float y1 = MenuTop - i * (TabH + TabGap); float y0 = y1 - TabH; bool on = (i == tab); // ★★★ 四个元素的先后顺序绝不能改(2026-09-15 真机踩坑):CUI 的文字 / 图片在 Unity 里 // 也是射线目标,压在按钮上会把点击吃掉。定稿 = 底 → 装饰 → 文字 → 最后垫全透明按钮。 Veil(c, PanelName, MenuX0 + 6f, y0, MenuX1 - 8f, y1, on ? Th("accentbg", ColAccentBg) : ColCard, s); if (on) Veil(c, PanelName, MenuX0 + 6f, y0, MenuX0 + 9f, y1, Th("accent", ColAccent), s); c.Add(MkLabel(TabNames[i], MenuX0 + 20f, y0, MenuX1 - 10f, y1, 13, TextAnchor.MiddleLeft, on ? ColOnAccent : ColTextDim, s), PanelName); c.Add(MkButton($"ecocenter.ui.tab {i}", "", MenuX0, y0, MenuX1, y1, 12, "0 0 0 0", "0 0 0 0", s), PanelName); } // ---- 菜单底部:我的钱包小卡 ---- // ★ v1.0.2 用户截图反馈:管理员菜单是 7 项(多一个「管理后台」),原来这张小卡 // top=218,把第 7 项「使用说明」(y 194~234)整个盖住。现在菜单 7 项压到 y=198 结束, // 小卡整体下移到 22~188,行距收紧,7 项和小卡互不打架。 float top = 188f, bot = 22f; Veil(c, PanelName, MenuX0 + 6f, bot, MenuX1 - 6f, top, ColCard, s, PanelName + ".miniwallet"); Veil(c, PanelName, MenuX0 + 6f, top - 1f, MenuX1 - 6f, top, Th("line", ColLine), s); c.Add(MkLabel("我 的 钱 包", MenuX0 + 14f, top - 26f, MenuX1 - 12f, top - 8f, 11, TextAnchor.MiddleLeft, Th("accent", ColAccent), s), PanelName); float y = top - 44f; Img(c, PanelName, AssetSprite("coin.png"), MenuX0 + 14f, y - 16f, MenuX0 + 32f, y, "1 1 1 1", s); c.Add(MkLabel(_config.Settings.GoldName, MenuX0 + 36f, y - 20f, MenuX1 - 12f, y + 4f, 11, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); c.Add(MkLabel(Money(w.Gold), MenuX0 + 36f, y - 20f, MenuX1 - 12f, y + 4f, 12, TextAnchor.MiddleRight, CurColor(CurGold), s), PanelName); y -= 26f; Img(c, PanelName, AssetSprite("diamond.png"), MenuX0 + 14f, y - 16f, MenuX0 + 32f, y, "1 1 1 1", s); c.Add(MkLabel(_config.Settings.DiamondName, MenuX0 + 36f, y - 20f, MenuX1 - 12f, y + 4f, 11, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); c.Add(MkLabel(Money(w.Diamond), MenuX0 + 36f, y - 20f, MenuX1 - 12f, y + 4f, 12, TextAnchor.MiddleRight, CurColor(CurDiamond), s), PanelName); y -= 26f; double inG, outG, inD, outD; SumDay(player.userID, CurGold, _today, out inG, out outG); SumDay(player.userID, CurDiamond, _today, out inD, out outD); Veil(c, PanelName, MenuX0 + 6f, y + 2f, MenuX1 - 6f, y + 3f, Th("line", ColLine), s); c.Add(MkLabel("今 日 收 支", MenuX0 + 14f, y - 20f, MenuX1 - 12f, y - 2f, 10, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); y -= 22f; c.Add(MkLabel($"金币 +{Money(inG)} / -{Money(outG)}", MenuX0 + 14f, y - 16f, MenuX1 - 12f, y, 10, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); y -= 18f; c.Add(MkLabel($"钻石 +{Money(inD)} / -{Money(outD)}", MenuX0 + 14f, y - 16f, MenuX1 - 12f, y, 10, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); } // ---------------- 顶部横幅 ---------------- private void BuildBanner(CuiElementContainer c, BasePlayer player, PlayerWallet w, int tab, float s) { Veil(c, PanelName, MenuX0, BannerY0, BodyX1, BannerY1, Th("banner", ColBanner), s, PanelName + ".banner"); Veil(c, PanelName, MenuX0, BannerY0, BodyX1, BannerY0 + 1f, Th("accent", ColAccent), s); c.Add(MkLabel(_config.Settings.PanelTitle, MenuX0 + 18f, BannerY0 + 15f, MenuX0 + 340f, BannerY1 - 3f, 19, TextAnchor.MiddleLeft, Th("accent", ColAccent), s), PanelName); c.Add(MkLabel(_config.Settings.PanelSubtitle, MenuX0 + 18f, BannerY0 + 2f, MenuX0 + 480f, BannerY0 + 20f, 11, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); // 右侧:玩家名 + 余额 string who = $"{ShortName(DN(player), 14)}" + $" {_config.Settings.GoldName} {Money(w.Gold)}" + $" {_config.Settings.DiamondName} {Money(w.Diamond)}"; c.Add(MkLabel(who, 420f, BannerY0 + 10f, BodyX1 - 78f, BannerY1 - 6f, 13, TextAnchor.MiddleRight, ColText, s), PanelName); c.Add(MkButton("ecocenter.ui.close", "关 闭", BodyX1 - 68f, BannerY0 + 8f, BodyX1 - 10f, BannerY1 - 8f, 12, ColAccentBg, ColOnAccent, s), PanelName); } // ---------------- 内容区通用零件 ---------------- // 页标题 + 右上角说明 + 一条分隔线(位置统一在这里,各页不许再自己写一套) private void Head(CuiElementContainer c, string title, string right, float s) { c.Add(MkLabel(title, BodyX0 + 4f, BodyY1 - 46f, BodyX0 + 420f, BodyY1 - 8f, 17, TextAnchor.MiddleLeft, Th("accent", ColAccent), s), PanelName); if (!string.IsNullOrEmpty(right)) c.Add(MkLabel(right, BodyX1 - 560f, BodyY1 - 44f, BodyX1 - 4f, BodyY1 - 10f, 11, TextAnchor.MiddleRight, ColTextDim, s), PanelName); Veil(c, PanelName, BodyX0 + 4f, BodyY1 - 52f, BodyX1 - 4f, BodyY1 - 51f, Th("line", ColLine), s); } // 空数据提示 private void EmptyHint(CuiElementContainer c, string text, float s) { c.Add(MkLabel(text, BodyX0 + 20f, BodyY0 + 220f, BodyX1 - 20f, BodyY0 + 290f, 13, TextAnchor.MiddleCenter, ColTextDim, s), PanelName); } #endregion #region ===== 面板:7 个页签 ===== // ---------------- 页签通用零件 ---------------- // 大数字卡(今日收入 / 今日支出 / 存款合计 都用它) private void StatCard(CuiElementContainer c, float x0, float x1, float y0, float y1, string title, string big, string bigColor, string sub, float s) { Card(c, PanelName, x0, y0, x1, y1, s); // ★ v1.0.2:卡高从 100 压到 94,三行必须留出间隙 —— // 原来 30pt 大数字(y1-74~y1-32)会咬住下面那行小字(y0+10~y0+32)。 c.Add(MkLabel(title, x0 + 16f, y1 - 26f, x1 - 16f, y1 - 6f, 11, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); c.Add(MkLabel(big, x0 + 16f, y1 - 64f, x1 - 16f, y1 - 28f, 26, TextAnchor.MiddleLeft, bigColor, s), PanelName); if (!string.IsNullOrEmpty(sub)) c.Add(MkLabel(sub, x0 + 16f, y0 + 8f, x1 - 16f, y0 + 28f, 10, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); } // 小格子(管理后台的一排统计数字) private void Cell(CuiElementContainer c, float x0, float x1, float y0, float y1, string title, string val, string valColor, float s) { Card(c, PanelName, x0, y0, x1, y1, s); c.Add(MkLabel(title, x0 + 8f, y1 - 26f, x1 - 8f, y1 - 6f, 11, TextAnchor.MiddleCenter, ColTextDim, s), PanelName); c.Add(MkLabel(val, x0 + 8f, y0 + 14f, x1 - 8f, y1 - 30f, 20, TextAnchor.MiddleCenter, valColor, s), PanelName); } // 一条赚币途径 private class EarnRow { public string Name = ""; public string Unit = ""; // 单价说明 public string Today = ""; // 今日已赚 public double Pct; public string Cap = ""; // 上限说明 public bool Off; // 关了 } private List EarnRows(PlayerWallet w) { var e = _config.Earn; var list = new List(); if (!e.Enable) { list.Add(new EarnRow { Name = "赚币功能已关闭", Unit = "服主在 config 里关了「赚金币 Earn → 总开关」", Off = true }); return list; } if (e.EnableGather) list.Add(new EarnRow { Name = "采集资源", Unit = $"每份 {Money(e.GoldPerGatherUnit)} 金币", Today = "今日已赚 " + Money(w.TodayGather), Pct = e.GatherDailyCap > 0 ? w.TodayGather / e.GatherDailyCap : 1, Cap = e.GatherDailyCap > 0 ? $"上限 {Money(e.GatherDailyCap)}(剩 {Money(Math.Max(0, e.GatherDailyCap - w.TodayGather))})" : "无上限", }); if (e.EnableKill) list.Add(new EarnRow { Name = "击杀玩家", Unit = $"每人 {Money(e.GoldPerPlayerKill)} 金币", Today = "今日已赚 " + Money(w.TodayKill), Pct = e.KillDailyCap > 0 ? w.TodayKill / e.KillDailyCap : 1, Cap = e.KillDailyCap > 0 ? $"上限 {Money(e.KillDailyCap)}(剩 {Money(Math.Max(0, e.KillDailyCap - w.TodayKill))})" : "无上限", }); if (e.GoldPerNpcKill > 0) list.Add(new EarnRow { Name = "击杀 NPC", Unit = $"每只 {Money(e.GoldPerNpcKill)} 金币", Today = "与击杀共用上限", Pct = 1, Cap = "见「击杀玩家」那一行", }); if (e.EnableRecycle) list.Add(new EarnRow { Name = "回收物品", Unit = $"每件 {Money(e.GoldPerRecycleItem)} 金币", Today = "今日已赚 " + Money(w.TodayRecycle), Pct = e.RecycleDailyCap > 0 ? w.TodayRecycle / e.RecycleDailyCap : 1, Cap = e.RecycleDailyCap > 0 ? $"上限 {Money(e.RecycleDailyCap)}(剩 {Money(Math.Max(0, e.RecycleDailyCap - w.TodayRecycle))})" : "无上限", }); if (e.EnableOnline) list.Add(new EarnRow { Name = "在线时长", Unit = $"每分钟 {Money(e.GoldPerOnlineMinute)} 金币", Today = "今日已赚 " + Money(w.TodayOnline), Pct = e.OnlineDailyCap > 0 ? w.TodayOnline / e.OnlineDailyCap : 1, Cap = e.OnlineDailyCap > 0 ? $"上限 {Money(e.OnlineDailyCap)}(剩 {Money(Math.Max(0, e.OnlineDailyCap - w.TodayOnline))})" : "无上限", }); list.Add(new EarnRow { Name = "商城卖出(以后开发)", Unit = "定价由商城决定", Today = "经济中心只负责记账", Pct = 1, Cap = "商城调用 EcoCenter 的对外接口入账", }); return list; } // ================= ① 银行 ================= private void BuildBank(CuiElementContainer c, BasePlayer player, PlayerWallet w, float s) { double inG, outG, inD, outD; SumDay(player.userID, CurGold, _today, out inG, out outG); SumDay(player.userID, CurDiamond, _today, out inD, out outD); int rank = MyRank(player.userID, CurGold); Head(c, "银 行 总 览", $"数据日期 {_today} · 每日 0 点自动重置今日统计 · {CurName(CurGold)}榜第 {rank} 名", s); // ---------- 三张大卡(x 三段等宽,y 400~500)---------- float cw = (BodyX1 - BodyX0 - 2f * 12f) / 3f; float y0 = 408f, y1 = 502f; // ★ v1.0.2 上移,给「钱包明细」腾高度 StatCard(c, BodyX0, BodyX0 + cw, y0, y1, $"今日收入({_today.Substring(5)})", $"{Money(inG)} / {Money(inD)}", ColIn, $"其中 {CurName(CurGold)} {Money(inG)} {CurName(CurDiamond)} {Money(inD)}", s); StatCard(c, BodyX0 + cw + 12f, BodyX0 + cw * 2f + 12f, y0, y1, "今日支出", $"{Money(outG)} / {Money(outD)}", ColOut, $"花掉 {CurName(CurGold)} {Money(outG)} {CurName(CurDiamond)} {Money(outD)}", s); StatCard(c, BodyX0 + cw * 2f + 24f, BodyX1, y0, y1, "存款合计(钱包余额)", Money(w.Gold + w.Diamond), Th("accent", ColAccent), $"{CurName(CurGold)} {Money(w.Gold)} + {CurName(CurDiamond)} {Money(w.Diamond)}", s); // ---------- 钱包明细(y 250~388)---------- // ★ v1.0.2 用户截图反馈:这张卡原来只有 138 高(250~388),但内容需要 190+ —— // 「标题 + 列头 + 4 行」的第 4 行(在线时长 / 累计签到)直接漏到卡片外面, // 正好压在下面 4 个「今日收益」格子上(截图里 今日击杀收益 旁边挂着「0.2 小时」)。 // 现在卡片拉到 202~396,行距 25,4 行全部落在卡内。 float my0 = 202f, my1 = 396f; Card(c, PanelName, BodyX0, my0, BodyX1, my1, s); c.Add(MkLabel("钱 包 明 细", BodyX0 + 16f, my1 - 28f, BodyX0 + 400f, my1 - 8f, 13, TextAnchor.MiddleLeft, Th("accent", ColAccent), s), PanelName); Veil(c, PanelName, BodyX0 + 16f, my1 - 34f, BodyX1 - 16f, my1 - 33f, Th("line", ColLine), s); // ★ 两列都是「标签左对齐 + 数值右对齐」—— 上一版把长句子直接左对齐写, // 结果「累计签到 0 次(当前连签 1 天 / 最高 0 天)」直接捅出卡片外(用户截图反馈) float lx0 = BodyX0 + 16f, lx1 = BodyX0 + 400f; float rx0 = BodyX0 + 416f, rx1 = BodyX1 - 16f; c.Add(MkLabel(CurName(CurGold) + "(玩出来的钱)", lx0, my1 - 60f, lx1, my1 - 38f, 12, TextAnchor.MiddleLeft, CurColor(CurGold), s), PanelName); c.Add(MkLabel(CurName(CurDiamond) + "(充值 + 签到)", rx0, my1 - 60f, rx1, my1 - 38f, 12, TextAnchor.MiddleLeft, CurColor(CurDiamond), s), PanelName); double todayMin = w.TodayOnlineSeconds / 60.0; double totalHour = w.TotalOnlineSeconds / 3600.0; string[][] lRows = { new[] { "当前余额", Money(w.Gold) + " " + CurSymbol(CurGold) }, new[] { "累计赚到", Money(w.TotalEarnedGold) }, new[] { "累计花掉", Money(w.TotalSpentGold) }, new[] { "在线时长", $"{Mathf.RoundToInt((float)todayMin)} 分钟(累计 {totalHour:0.#} 小时)" }, }; string[][] rRows = { new[] { "当前余额", Money(w.Diamond) + " " + CurSymbol(CurDiamond) }, new[] { "累计获得", Money(w.TotalEarnedDiamond) }, new[] { "累计花掉", Money(w.TotalSpentDiamond) }, new[] { "累计签到", $"{w.SignTotal} 次(连签 {w.SignStreak} / 最高 {w.BestStreak} 天)" }, }; for (int i = 0; i < 4; i++) { float yy1 = my1 - 80f - i * 25f; float yy0 = yy1 - 23f; c.Add(MkLabel(lRows[i][0], lx0, yy0, lx0 + 110f, yy1, 11, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); c.Add(MkLabel(lRows[i][1], lx0 + 110f, yy0, lx1, yy1, 12, TextAnchor.MiddleRight, ColText, s), PanelName); c.Add(MkLabel(rRows[i][0], rx0, yy0, rx0 + 110f, yy1, 11, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); c.Add(MkLabel(rRows[i][1], rx0 + 110f, yy0, rx1, yy1, 12, TextAnchor.MiddleRight, ColText, s), PanelName); } // ---------- 今日收益 4 格(y 130~230)---------- float ey0 = 110f, ey1 = 192f; float gw = (BodyX1 - BodyX0 - 3f * 12f) / 4f; string[] names = { "采集", "击杀", "回收", "在线" }; double[] vals = { w.TodayGather, w.TodayKill, w.TodayRecycle, w.TodayOnline }; for (int i = 0; i < 4; i++) { float x = BodyX0 + i * (gw + 12f); Cell(c, x, x + gw, ey0, ey1, "今日" + names[i] + "收益", Money(vals[i]), CurColor(CurGold), s); } // ---------- 按钮行(y 64~114)---------- float b0 = 60f, b1 = 98f; c.Add(MkButton("ecocenter.ui.tab 1", "看收支明细", BodyX0, b0, BodyX0 + 190f, b1, 13, ColAccentBg, ColOnAccent, s), PanelName); c.Add(MkButton("ecocenter.ui.tab 2", "怎么赚金币", BodyX0 + 200f, b0, BodyX0 + 390f, b1, 13, ColAccentBg, ColOnAccent, s), PanelName); c.Add(MkButton("ecocenter.ui.tab 3", "充值 / 签到", BodyX0 + 400f, b0, BodyX0 + 590f, b1, 13, ColAccentBg, ColOnAccent, s), PanelName); c.Add(MkButton($"ecocenter.ui.refresh {TBank}", "刷 新", BodyX1 - 200f, b0, BodyX1, b1, 13, ColCard, ColText, s), PanelName); Foot(c, _config.Settings.FooterText + $" · {CurName(CurGold)}靠玩、{CurName(CurDiamond)}靠充 + 签到,两种钱不互通", s); } // ================= ② 收支 ================= private void BuildFlow(CuiElementContainer c, BasePlayer player, PlayerWallet w, float s) { string cur; if (!_flowCur.TryGetValue(player.userID, out cur) || NormCur(cur) == null) cur = CurGold; cur = NormCur(cur); int page; if (!_flowPage.TryGetValue(player.userID, out page) || page < 0) page = 0; int per = Mathf.Clamp(_config.Records.RowsPerPage, 4, 8); // 一次最多数 999 条(够 124 页),避免为了算总页数把整个流水读出来 int total = GetFlows(player.userID, cur, 999).Count; int maxPage = Math.Max(0, (total - 1) / per); if (page > maxPage) { page = maxPage; _flowPage[player.userID] = page; } var rows = GetFlows(player.userID, cur, (page + 1) * per).Skip(page * per).Take(per).ToList(); Head(c, "收 支 明 细", $"共 {total} 条 · 保留最近 {_config.Records.KeepDays} 天 · 第 {page + 1} / {maxPage + 1} 页", s); // ---------- 币种切换(y 452~490)---------- float by0 = 452f, by1 = 490f; bool isG = cur == CurGold; c.Add(MkButton("ecocenter.ui.flow gold 0", CurName(CurGold) + " 明细", BodyX0, by0, BodyX0 + 126f, by1, 13, isG ? ColAccentBg : ColCard, isG ? ColOnAccent : ColTextDim, s), PanelName); c.Add(MkButton("ecocenter.ui.flow diamond 0", CurName(CurDiamond) + " 明细", BodyX0 + 136f, by0, BodyX0 + 262f, by1, 13, !isG ? ColAccentBg : ColCard, !isG ? ColOnAccent : ColTextDim, s), PanelName); // ---------- 翻页 ---------- bool canPrev = page > 0, canNext = page < maxPage; c.Add(MkButton($"ecocenter.ui.flow {cur} -1", "◀ 上一页", BodyX1 - 208f, by0, BodyX1 - 132f, by1, 12, canPrev ? ColCard : ColCardSoft, canPrev ? ColText : ColTextDim, s), PanelName); c.Add(MkLabel($"{(canPrev ? page : 1)} / {maxPage + 1}", BodyX1 - 128f, by0, BodyX1 - 80f, by1, 11, TextAnchor.MiddleCenter, ColTextDim, s), PanelName); c.Add(MkButton($"ecocenter.ui.flow {cur} 1", "下一页 ▶", BodyX1 - 76f, by0, BodyX1, by1, 12, canNext ? ColCard : ColCardSoft, canNext ? ColText : ColTextDim, s), PanelName); // ---------- 表头(y 414~446)---------- float hy0 = 414f, hy1 = 446f; Veil(c, PanelName, BodyX0, hy0, BodyX1, hy1, ColCardSoft, s, PanelName + ".flowhead"); Veil(c, PanelName, BodyX0, hy0, BodyX1, hy0 + 1f, Th("line", ColLine), s); float cx0 = BodyX0 + 16f, cxT = cx0 + 60f, cxS = cxT + 92f, cxN = cxS + 314f, cxA = cxN + 156f, cxB = BodyX1 - 16f; c.Add(MkLabel("时间", cx0, hy0, cxT, hy1, 11, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); c.Add(MkLabel("来源", cxT, hy0, cxS, hy1, 11, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); c.Add(MkLabel("说明", cxS, hy0, cxN, hy1, 11, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); c.Add(MkLabel("金额", cxN, hy0, cxA, hy1, 11, TextAnchor.MiddleRight, ColTextDim, s), PanelName); c.Add(MkLabel("变动后余额", cxA, hy0, cxB, hy1, 11, TextAnchor.MiddleRight, ColTextDim, s), PanelName); // ---------- 列表(★ 第一行排最高的 y,往下递减;行高 44 刚好放 8 行不压页脚)---------- if (rows.Count == 0) { EmptyHint(c, "还没有任何 " + CurName(cur) + " 的收支记录。\n去采点矿、砍点树,或者到「充值签到」页领一下今天的奖励。", s); Foot(c, _config.Settings.FooterText, s); return; } float listTop = 408f, rowH = 44f; for (int i = 0; i < rows.Count; i++) { var r = rows[i]; float ry1 = listTop - i * rowH; float ry0 = ry1 - (rowH - 4f); Veil(c, PanelName, BodyX0, ry0, BodyX1, ry1, i % 2 == 0 ? ColCard : ColCardSoft, s); bool plus = r.A > 0; c.Add(MkLabel(r.T, cx0, ry0, cxT, ry1, 11, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); c.Add(MkLabel(ShortName(r.S, 6), cxT, ry0, cxS, ry1, 11, TextAnchor.MiddleLeft, plus ? ColIn : ColOut, s), PanelName); c.Add(MkLabel(ShortName(r.N, 20), cxS, ry0, cxN, ry1, 11, TextAnchor.MiddleLeft, ColText, s), PanelName); c.Add(MkLabel((plus ? "+" : "-") + Money(Math.Abs(r.A)), cxN, ry0, cxA, ry1, 12, TextAnchor.MiddleRight, plus ? ColIn : ColOut, s), PanelName); c.Add(MkLabel(Money(r.B), cxA, ry0, cxB, ry1, 11, TextAnchor.MiddleRight, ColTextDim, s), PanelName); } Foot(c, $"★ 同一来源 {_config.Records.FlowMergeSeconds:0} 秒内的进账自动并成一条(config 可改);" + "商城、签到、充值、管理员发放在这里全都看得见", s); } // ================= ③ 赚金币 ================= private void BuildEarn(CuiElementContainer c, BasePlayer player, PlayerWallet w, float s) { var rows = EarnRows(w); Head(c, "赚 金 币", _config.Earn.Enable ? "下面每一项的单价和每日上限,都能在 config 的「赚金币 Earn」里改" : "★ 赚币总开关当前是关闭的", s); float top = 492f, bottom = 62f, gap = 10f; int n = Math.Max(1, rows.Count); float h = (top - bottom - (n - 1) * gap) / n; for (int i = 0; i < rows.Count; i++) { var r = rows[i]; float y1 = top - i * (h + gap); float y0 = y1 - h; Card(c, PanelName, BodyX0, y0, BodyX1, y1, s); // 行内按比例分,行数变多(开了击杀 NPC)也不会互相压 float inner = h - 16f; float nameH = Mathf.Min(24f, inner * 0.38f); float unitH = Mathf.Min(20f, inner * 0.30f); float nameY1 = y1 - 8f; float nameY0 = nameY1 - nameH; float unitY1 = nameY0 - 2f; float unitY0 = unitY1 - unitH; c.Add(MkLabel(r.Name, BodyX0 + 16f, nameY0, BodyX0 + 430f, nameY1, 14, TextAnchor.MiddleLeft, r.Off ? ColOut : CurColor(CurGold), s), PanelName); c.Add(MkLabel(r.Today, BodyX0 + 430f, nameY0, BodyX1 - 16f, nameY1, 13, TextAnchor.MiddleRight, r.Off ? ColTextDim : ColText, s), PanelName); c.Add(MkLabel(r.Unit, BodyX0 + 16f, unitY0, BodyX0 + 560f, unitY1, 11, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); c.Add(MkLabel(r.Cap, BodyX0 + 560f, unitY0, BodyX1 - 16f, unitY1, 10, TextAnchor.MiddleRight, ColTextDim, s), PanelName); if (!r.Off) Bar(c, PanelName, BodyX0 + 16f, y0 + 8f, BodyX1 - 16f, y0 + 18f, r.Pct, CurColor(CurGold), s); } Foot(c, (_config.Earn.Enable ? DailyMaxText() + " · " : "") + "进度条 = 今日已达上限的比例;每天 0 点自动重置。" + $" · {CurName(CurDiamond)}不在这里产出(只能充值和签到拿)", s); } // ================= ④ 充值 / 签到 ================= private void BuildRecharge(CuiElementContainer c, BasePlayer player, PlayerWallet w, float s) { bool signedToday = w.LastSignDate == _today; bool continued = signedToday || w.LastSignDate == Yesterday(); int claimed = continued ? Math.Max(0, w.SignStreak) : 0; int streak = signedToday ? Math.Max(1, w.SignStreak) : (continued ? w.SignStreak + 1 : (_config.SignIn.ResetOnMiss ? 1 : w.SignStreak + 1)); if (streak < 1) streak = 1; double todayReward = SignRewardOf(streak); if (IsVip(player)) todayReward *= _config.SignIn.VipMultiplier; Head(c, "充 值 · 签 到", $"{CurName(CurDiamond)} = 充值 + 签到;{CurName(CurGold)} 换不了 {CurName(CurDiamond)},反过来也不行", s); // ================== 左:每日签到 ================== float lx0 = BodyX0, lx1 = BodyX0 + 386f; float ly0 = 58f, ly1 = 492f; Card(c, PanelName, lx0, ly0, lx1, ly1, s); c.Add(MkLabel("每 日 签 到", lx0 + 18f, ly1 - 34f, lx1 - 18f, ly1 - 10f, 14, TextAnchor.MiddleLeft, Th("accent", ColAccent), s), PanelName); c.Add(MkLabel($"连签 {w.SignStreak} 天 累计 {w.SignTotal} 次 最高 {w.BestStreak} 天", lx0 + 18f, ly1 - 58f, lx1 - 18f, ly1 - 36f, 11, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); Veil(c, PanelName, lx0 + 18f, ly1 - 66f, lx1 - 18f, ly1 - 65f, Th("line", ColLine), s); // ---- 7 天格子(x 184 ~ 542,y 366~410)---- var rewards = _config.SignIn.Rewards ?? new List(); int cycle = rewards.Count > 0 ? rewards.Count : 7; if (cycle < 1) cycle = 7; int cyclePos = claimed % cycle; bool cycleFull = claimed > 0 && cyclePos == 0; float bw = 46f, bgap = 6f; float bx = lx0 + 18f; float gy0 = 366f, gy1 = 410f; for (int i = 0; i < cycle && i < 7; i++) { int day = rewards.Count > 0 ? rewards[i].Day : (i + 1); double amt = rewards.Count > 0 ? rewards[i].Diamonds : 0; float x = bx + i * (bw + bgap); bool done = i < cyclePos || cycleFull; bool isToday = !signedToday && i == cyclePos && !cycleFull; Veil(c, PanelName, x, gy0, x + bw, gy1, done ? ColAccentBg : ColCardSoft, s); if (isToday) Veil(c, PanelName, x, gy1 - 2f, x + bw, gy1, Th("accent", ColAccent), s); c.Add(MkLabel("第" + day + "天", x, gy1 - 18f, x + bw, gy1 - 4f, 9, TextAnchor.MiddleCenter, done ? ColOnAccent : ColTextDim, s), PanelName); c.Add(MkLabel(Money(amt), x, gy0 + 18f, x + bw, gy1 - 18f, 11, TextAnchor.MiddleCenter, done ? ColOnAccent : ColText, s), PanelName); c.Add(MkLabel(done ? "已领" : (isToday ? "今天" : "未领"), x, gy0 + 3f, x + bw, gy0 + 17f, 9, TextAnchor.MiddleCenter, done ? ColOnAccent : (isToday ? ColIn : ColTextDim), s), PanelName); } c.Add(MkLabel($"第 {streak} 天可领", lx0 + 18f, 336f, lx1 - 18f, 360f, 13, TextAnchor.MiddleLeft, ColText, s), PanelName); c.Add(MkLabel($"{Money(todayReward)} {CurSymbol(CurDiamond)}" + (IsVip(player) ? " (VIP 双倍已生效)" : ""), lx0 + 18f, 300f, lx1 - 18f, 334f, 22, TextAnchor.MiddleLeft, ColText, s), PanelName); // ---- 签到按钮(y 246~292)---- if (!_config.SignIn.Enable) c.Add(MkButton("ecocenter.ui.refresh 3", "签到功能已关闭", lx0 + 34f, 246f, lx1 - 34f, 292f, 13, ColCardSoft, ColTextDim, s), PanelName); else if (signedToday) c.Add(MkButton("ecocenter.ui.refresh 3", "今天已签到 ✔ 明天再来", lx0 + 34f, 246f, lx1 - 34f, 292f, 13, ColCardSoft, ColTextDim, s), PanelName); else c.Add(MkButton("ecocenter.ui.sign", "立 即 签 到", lx0 + 34f, 246f, lx1 - 34f, 292f, 15, ColAccentBg, ColOnAccent, s), PanelName); // ---- 说明(3 行,各留 24 行高,不再挤成一坨)---- c.Add(MkLabel("连续签到不断,第 7 天有大奖;掉签会从第 1 天重新开始。", lx0 + 18f, 212f, lx1 - 18f, 236f, 11, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); c.Add(MkLabel($"{CurName(CurDiamond)}怎么来:① 签到 ② 充值(见右边) ③ 服主发放", lx0 + 18f, 188f, lx1 - 18f, 212f, 11, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); c.Add(MkLabel($"{CurName(CurDiamond)}怎么花:商城里的物资、特殊道具都能用它买。", lx0 + 18f, 164f, lx1 - 18f, 188f, 11, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); c.Add(MkLabel($"★ {CurName(CurGold)} 与 {CurName(CurDiamond)} 互不兑换 —— 挂机刷不出{CurName(CurDiamond)}。", lx0 + 18f, 130f, lx1 - 18f, 154f, 11, TextAnchor.MiddleLeft, ColWarn, s), PanelName); c.Add(MkLabel($"本页数据每天 0 点重置「今日」,签到奖励配置见 config 的「签到 SignIn」。", lx0 + 18f, 106f, lx1 - 18f, 130f, 10, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); // ================== 右:充值钻石 ================== float rx0 = BodyX0 + 396f, rx1 = BodyX1; Card(c, PanelName, rx0, ly0, rx1, ly1, s); c.Add(MkLabel("充 值 钻 石", rx0 + 18f, ly1 - 34f, rx1 - 18f, ly1 - 10f, 14, TextAnchor.MiddleLeft, Th("accent", ColAccent), s), PanelName); Veil(c, PanelName, rx0 + 18f, ly1 - 42f, rx1 - 18f, ly1 - 41f, Th("line", ColLine), s); // ---- 收款码(左,164x164)+ 付款步骤(右)---- float qx0 = rx0 + 18f, qx1 = qx0 + 164f; float qy0 = 288f, qy1 = qy0 + 164f; string qr = _config.Recharge.Enable ? AssetSprite(_config.Recharge.WechatQrImage) : null; Veil(c, PanelName, qx0 - 4f, qy0 - 4f, qx1 + 4f, qy1 + 4f, ColCardSoft, s); if (!string.IsNullOrEmpty(qr)) Img(c, PanelName, qr, qx0, qy0, qx1, qy1, "1 1 1 1", s); else c.Add(MkLabel("收款码未放置\n\n把微信收款码导出成 PNG:\n" + _config.Recharge.WechatQrImage + "\n放进 oxide/data/EcoCenter/assets/\n然后 oxide.reload EcoCenter", qx0, qy0, qx1, qy1, 10, TextAnchor.MiddleCenter, ColTextDim, s), PanelName); c.Add(MkLabel(_config.Recharge.QrHint, qx0 - 4f, qy0 - 26f, qx1 + 4f, qy0 - 4f, 11, TextAnchor.MiddleCenter, ColTextDim, s), PanelName); string steps = _config.Recharge.PaymentText ?? ""; c.Add(MkLabel(steps, qx1 + 16f, qy0 - 4f, rx1 - 18f, qy1 + 4f, 11, TextAnchor.UpperLeft, ColText, s), PanelName); // ---- 充值套餐(y 112~232,每行 24,跟兑换码区分得干干净净)---- c.Add(MkLabel("充 值 套 餐", rx0 + 18f, 258f, rx1 - 18f, 282f, 13, TextAnchor.MiddleLeft, Th("accent", ColAccent), s), PanelName); Veil(c, PanelName, rx0 + 18f, 256f, rx1 - 18f, 255f, Th("line", ColLine), s); float pkx = rx0 + 22f, pky0 = 112f, pkH = 24f; c.Add(MkLabel("套餐", pkx, 232f, rx1 - 200f, 254f, 10, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); c.Add(MkLabel("价格", rx1 - 200f, 232f, rx1 - 130f, 254f, 10, TextAnchor.MiddleRight, ColTextDim, s), PanelName); c.Add(MkLabel(CurName(CurDiamond), rx1 - 126f, 232f, rx1 - 22f, 254f, 10, TextAnchor.MiddleRight, ColTextDim, s), PanelName); var packs = _config.Recharge.Packages ?? new List(); for (int i = 0; i < packs.Count && i < 5; i++) { var p = packs[i]; float py1 = 232f - i * pkH; float py0 = py1 - pkH; if (i % 2 == 0) Veil(c, PanelName, pkx - 4f, py0 + 2f, rx1 - 18f, py1 - 2f, ColCardSoft, s); c.Add(MkLabel(ShortName(p.Name, 10), pkx, py0, rx1 - 200f, py1, 11, TextAnchor.MiddleLeft, ColText, s), PanelName); c.Add(MkLabel("¥ " + Money(p.Rmb), rx1 - 200f, py0, rx1 - 130f, py1, 11, TextAnchor.MiddleRight, ColWarn, s), PanelName); c.Add(MkLabel(Money(p.Diamonds + p.Bonus) + " " + CurSymbol(CurDiamond) + (p.Bonus > 0 ? $"(含赠 {Money(p.Bonus)})" : ""), rx1 - 126f, py0, rx1 - 22f, py1, 11, TextAnchor.MiddleRight, CurColor(CurDiamond), s), PanelName); } // ---- 兑换码 / 客服(y 68~106)---- Veil(c, PanelName, rx0 + 18f, 104f, rx1 - 18f, 105f, Th("line", ColLine), s); c.Add(MkLabel("已有兑换码? 在聊天框输入: /ecode 你的兑换码", rx0 + 18f, 78f, rx1 - 18f, 102f, 11, TextAnchor.MiddleLeft, ColText, s), PanelName); string support = ""; if (!string.IsNullOrEmpty(_config.Recharge.SupportWechat)) support += "微信 " + _config.Recharge.SupportWechat + " "; if (!string.IsNullOrEmpty(_config.Recharge.SupportQQ)) support += "QQ " + _config.Recharge.SupportQQ + " "; c.Add(MkLabel(string.IsNullOrEmpty(support) ? _config.Recharge.SupportText : support + _config.Recharge.SupportText, rx0 + 18f, 66f, rx1 - 18f, 86f, 10, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); } // ================= ⑤ 财富排行(★ 人多了也能翻页)================= private const int TopPerPage = 20; // 一页 20 名(左右两列各 10) private const int AdmPerPage = 7; // ★ v1.0.2 管理后台「在线玩家」一页 7 行(原来写死 Take(7),人多就看不到) private void BuildTop(CuiElementContainer c, BasePlayer player, PlayerWallet w, float s) { string cur; if (!_topCur.TryGetValue(player.userID, out cur) || NormCur(cur) == null) cur = CurGold; cur = NormCur(cur); int totalPeople = _wallets.Count; int maxPage = Math.Max(0, (totalPeople - 1) / TopPerPage); int page; if (!_topPage.TryGetValue(player.userID, out page) || page < 0) page = 0; if (page > maxPage) { page = maxPage; _topPage[player.userID] = page; } int mine = MyRank(player.userID, cur); Head(c, "财 富 排 行", $"我的{CurName(cur)}:{Money(Balance(player.userID, cur))} · 第 {mine} 名 · " + $"全服 {totalPeople} 人 · 第 {page + 1} / {maxPage + 1} 页", s); // ---------- 币种切换(y 452~490)---------- float by0 = 452f, by1 = 490f; bool isG = cur == CurGold; c.Add(MkButton("ecocenter.ui.top gold 0", CurName(CurGold) + "榜", BodyX0, by0, BodyX0 + 116f, by1, 13, isG ? ColAccentBg : ColCard, isG ? ColOnAccent : ColTextDim, s), PanelName); c.Add(MkButton("ecocenter.ui.top diamond 0", CurName(CurDiamond) + "榜", BodyX0 + 126f, by0, BodyX0 + 242f, by1, 13, !isG ? ColAccentBg : ColCard, !isG ? ColOnAccent : ColTextDim, s), PanelName); // ---------- 翻页 + 刷新 ---------- bool canPrev = page > 0, canNext = page < maxPage; c.Add(MkButton($"ecocenter.ui.top {cur} -1", "◀ 上一页", BodyX1 - 268f, by0, BodyX1 - 192f, by1, 12, canPrev ? ColCard : ColCardSoft, canPrev ? ColText : ColTextDim, s), PanelName); c.Add(MkLabel($"{page + 1} / {maxPage + 1}", BodyX1 - 188f, by0, BodyX1 - 140f, by1, 11, TextAnchor.MiddleCenter, ColTextDim, s), PanelName); c.Add(MkButton($"ecocenter.ui.top {cur} 1", "下一页 ▶", BodyX1 - 136f, by0, BodyX1 - 60f, by1, 12, canNext ? ColCard : ColCardSoft, canNext ? ColText : ColTextDim, s), PanelName); c.Add(MkButton($"ecocenter.ui.refresh {TRank}", "刷 新", BodyX1 - 54f, by0, BodyX1, by1, 12, ColCard, ColText, s), PanelName); var top = TopList(cur, (page + 1) * TopPerPage).Skip(page * TopPerPage).Take(TopPerPage).ToList(); if (top.Count == 0) { EmptyHint(c, $"还没有玩家数据。\n有人进服就会自动建钱包(新玩家起始 {Money(_config.Settings.StartingGold)} " + $"{CurName(CurGold)})。", s); Foot(c, _config.Settings.FooterText, s); return; } // ---------- 表头(y 412~442)---------- Veil(c, PanelName, BodyX0, 412f, BodyX1, 442f, ColCardSoft, s, PanelName + ".tophead"); Veil(c, PanelName, BodyX0, 412f, BodyX1, 413f, Th("line", ColLine), s); // ---------- 双列 × 每列 10 行,y 406 往下递减 ---------- float listTop = 406f, rowH = 35f; float colW = (BodyX1 - BodyX0 - 16f) / 2f; for (int i = 0; i < top.Count; i++) { var kv = top[i]; bool left = i < 10; int idx = left ? i : i - 10; float x0 = left ? BodyX0 : BodyX0 + colW + 16f; float x1 = x0 + colW; float y1 = listTop - idx * rowH; float y0 = y1 - (rowH - 4f); int rankNo = page * TopPerPage + i + 1; bool me = kv.Key == player.userID; Veil(c, PanelName, x0, y0, x1, y1, me ? ColAccentBg : (i % 2 == 0 ? ColCard : ColCardSoft), s); string bcol = rankNo == 1 ? ColGold : (rankNo == 2 ? ColText : (rankNo == 3 ? ColOut : ColTextDim)); c.Add(MkLabel(rankNo.ToString(), x0 + 2f, y0, x0 + 42f, y1, 13, TextAnchor.MiddleCenter, me ? ColOnAccent : bcol, s), PanelName); c.Add(MkLabel(ShortName(kv.Value.LastName, 11), x0 + 48f, y0, x1 - 150f, y1, 12, TextAnchor.MiddleLeft, me ? ColOnAccent : ColText, s), PanelName); c.Add(MkLabel(Money(GetBal(kv.Value, cur)), x1 - 144f, y0, x1 - 10f, y1, 13, TextAnchor.MiddleRight, me ? ColOnAccent : CurColor(cur), s), PanelName); } Foot(c, $"只显示前 {TopPerPage} 名一页,点上面的按钮翻页。" + $"{CurName(CurGold)}靠玩、{CurName(CurDiamond)}靠充 —— 榜上每一分都查得到来源。", s); } // ================= ⑥ 管理后台(仅 ecocenter.admin)================= private void BuildAdmin(CuiElementContainer c, BasePlayer player, PlayerWallet w, float s) { Head(c, "管 理 后 台", $"只有 {PermAdmin} / 服务器管理员能进这一页 · 这里发/扣的钱会记「管理员」流水", s); // ---------- 全服报表 6 格(y 436~500)---------- double sumG = 0, sumD = 0, todayInG = 0, todayOutG = 0; foreach (var kv in _wallets) { sumG += kv.Value.Gold; sumD += kv.Value.Diamond; todayInG += kv.Value.TodayIn; todayOutG += kv.Value.TodayOut; } int pending = _orders.Values.Count(o => o.Status == "pending"); int unusedCodes = _codes.Values.Count(x => x.UsedBy == 0); float cy0 = 436f, cy1 = 500f; float cwid = (BodyX1 - BodyX0 - 5f * 10f) / 6f; string[] ct = { "钱包总数", "全服金币", "全服钻石", "今日总进", "今日总出", "公告" }; string[] cv = { _wallets.Count.ToString(), Money(sumG), Money(sumD), Money(todayInG), Money(todayOutG), $"码 {unusedCodes}" }; string[] cc = { ColText, ColGold, ColDiamond, ColIn, ColOut, ColTextDim }; for (int i = 0; i < 6; i++) { float x = BodyX0 + i * (cwid + 10f); Cell(c, x, x + cwid, cy0, cy1, ct[i], cv[i], cc[i], s); } // ---------- 在线玩家:一键发放 / 扣除(y 58~420,★ v1.0.2 加翻页)---------- float py1 = 420f, py0 = 58f; Card(c, PanelName, BodyX0, py0, BodyX1, py1, s); c.Add(MkLabel("在 线 玩 家  一 键 发 放 / 扣 除", BodyX0 + 16f, py1 - 34f, BodyX0 + 300f, py1 - 10f, 13, TextAnchor.MiddleLeft, Th("accent", ColAccent), s), PanelName); Veil(c, PanelName, BodyX0 + 16f, py1 - 40f, BodyX1 - 16f, py1 - 39f, Th("line", ColLine), s); // ★ v1.0.2:原来写死 Take(7),服里超过 7 个人就看不全、也发不了。 // 现在一页 7 行 + 上一页 / 下一页,号码存在 _admPage(关面板不清)。 var allOnline = BasePlayer.activePlayerList.Where(x => x != null && x.IsConnected).ToList(); int admPer = Mathf.Clamp(AdmPerPage, 4, 8); int admMax = Math.Max(0, (allOnline.Count - 1) / admPer); int admPg; if (!_admPage.TryGetValue(player.userID, out admPg) || admPg < 0) admPg = 0; if (admPg > admMax) { admPg = admMax; _admPage[player.userID] = admPg; } var online = allOnline.Skip(admPg * admPer).Take(admPer).ToList(); c.Add(MkLabel($"在线 {allOnline.Count} 人 · 待发放订单 {pending} · 未使用兑换码 {unusedCodes} · 第 {admPg + 1} / {admMax + 1} 页", BodyX0 + 440f, py1 - 32f, BodyX1 - 200f, py1 - 10f, 11, TextAnchor.MiddleRight, ColTextDim, s), PanelName); c.Add(MkButton("ecocenter.ui.admpage -1", "上一页", BodyX1 - 192f, py1 - 40f, BodyX1 - 110f, py1 - 8f, 11, ColCard, admPg > 0 ? ColText : ColTextDim, s), PanelName); c.Add(MkButton("ecocenter.ui.admpage 1", "下一页", BodyX1 - 104f, py1 - 40f, BodyX1 - 16f, py1 - 8f, 11, ColCard, admPg < admMax ? ColText : ColTextDim, s), PanelName); float hy0 = py1 - 70f, hy1 = py1 - 44f; c.Add(MkLabel("玩家", BodyX0 + 16f, hy0, BodyX0 + 230f, hy1, 11, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); c.Add(MkLabel("当前余额", BodyX0 + 230f, hy0, BodyX0 + 390f, hy1, 11, TextAnchor.MiddleRight, ColTextDim, s), PanelName); c.Add(MkLabel("发放 / 扣除", BodyX0 + 396f, hy0, BodyX1 - 16f, hy1, 11, TextAnchor.MiddleCenter, ColTextDim, s), PanelName); float brow = 38f; for (int i = 0; i < online.Count; i++) { var p = online[i]; float ry1 = hy0 - 8f - i * brow; float ry0 = ry1 - (brow - 4f); var pw = GetWallet(p.userID); Veil(c, PanelName, BodyX0 + 8f, ry0, BodyX1 - 8f, ry1, i % 2 == 0 ? ColCardSoft : ColCard, s); c.Add(MkLabel(ShortName(DN(p), 12) + (IsAdmin(p) ? "(管)" : ""), BodyX0 + 16f, ry0, BodyX0 + 230f, ry1, 11, TextAnchor.MiddleLeft, ColText, s), PanelName); c.Add(MkLabel($"{Money(pw.Gold)} / {Money(pw.Diamond)}", BodyX0 + 230f, ry0, BodyX0 + 390f, ry1, 11, TextAnchor.MiddleRight, ColTextDim, s), PanelName); // 五个按钮:+1000金 / +1万金 / +10钻 / +100钻 / -1000金 float bx = BodyX0 + 396f; float bw2 = 76f, bg2 = 6f; c.Add(MkButton($"ecocenter.ui.adm {p.userID} gold 1000", "+1000金", bx, ry0 + 4f, bx + bw2, ry1 - 4f, 10, ColAccentBg, ColOnAccent, s), PanelName); bx += bw2 + bg2; c.Add(MkButton($"ecocenter.ui.adm {p.userID} gold 10000", "+1万金", bx, ry0 + 4f, bx + bw2, ry1 - 4f, 10, ColAccentBg, ColOnAccent, s), PanelName); bx += bw2 + bg2; c.Add(MkButton($"ecocenter.ui.adm {p.userID} diamond 10", "+10钻", bx, ry0 + 4f, bx + bw2, ry1 - 4f, 10, ColAccentBg, ColOnAccent, s), PanelName); bx += bw2 + bg2; c.Add(MkButton($"ecocenter.ui.adm {p.userID} diamond 100", "+100钻", bx, ry0 + 4f, bx + bw2, ry1 - 4f, 10, ColAccentBg, ColOnAccent, s), PanelName); bx += bw2 + bg2; c.Add(MkButton($"ecocenter.ui.adm {p.userID} gold -1000", "−1000金", bx, ry0 + 4f, BodyX1 - 16f, ry1 - 4f, 10, ColCard, ColOut, s), PanelName); } if (allOnline.Count == 0) c.Add(MkLabel("现在没有玩家在线。玩家不在线也能发 —— 用下面这两条命令:", BodyX0 + 16f, hy0 - 60f, BodyX1 - 16f, hy0 - 20f, 12, TextAnchor.MiddleLeft, ColTextDim, s), PanelName); Foot(c, "任意金额 / 玩家不在线 → 控制台命令:" + " ecocenter.recharge <订单号> <钻石数> <备注> | " + " ecocenter.give <玩家> <数量>", s); } // ★ v1.0.3(用户问「在家多久奖励多少 / 采集奖励多少 / 一天最高收益多少」): // 说明书的「怎么赚金币」原来只写「单价在 config」,玩家看完还是不知道能赚多少。 // 现在全部按 config 实时算出来 —— 单价、每日上限、挂满要多久、一天合计上限。 private string[] EarnHelpCol() { var e = _config.Earn; var L = new List { "【两种钱】", $"{CurName(CurGold)} —— 靠玩出来:采集矿石 / 树木、击杀玩家、", "回收物品、在线时长。每天都有上限,上限可改。", $"{CurName(CurDiamond)} —— 靠充值和签到。", "", "★ 两种钱互不兑换,谁也刷不出付费币。", "", "【怎么赚金币】", }; if (!e.Enable) { L.Add("★ 本服现在关闭了赚币功能。"); } else { L.Add(EarnHelpLine("采集", "每份矿 / 木头", e.EnableGather, e.GoldPerGatherUnit, e.GatherDailyCap)); L.Add(EarnHelpLine("击杀", "每击杀一名玩家", e.EnableKill, e.GoldPerPlayerKill, e.KillDailyCap)); L.Add(EarnHelpLine("回收", "拆解每件物品", e.EnableRecycle, e.GoldPerRecycleItem, e.RecycleDailyCap)); L.Add(EarnHelpLine("在线", "每分钟", e.EnableOnline, e.GoldPerOnlineMinute, e.OnlineDailyCap)); L.Add(""); L.Add("★ " + DailyMaxText()); L.Add(OnlineHoursText()); } L.Add(TipHelpText()); L.Add("同一来源 5 分钟内的进账并成一条,收支页不刷屏。"); return L.ToArray(); } // 一行赚币途径:「· 采集:每份矿 / 木头 +1 金币,每天最多 3,000」 private string EarnHelpLine(string who, string how, bool on, double unit, double cap) { if (!on || unit <= 0) return $"· {who}:(本服已关闭)"; string capTxt = cap > 0 ? $",每天最多 {Money(cap)}" : ",没有每日上限"; return $"· {who}:{how} +{Money(unit)} {_config.Settings.GoldSymbol}{capTxt}"; } /// ★ v1.0.4 「玩出来」的四类收益(采集/击杀/回收/在线)今日上限合计。 /// unlim = 其中有不限上限的项(这时算不出"赚满",聊天提示也就不停); /// cnt = 启用了几项。★ 说明书页和聊天提示共用这一份口径,避免两处漂移。 private double DailyEarnCap(out bool unlim, out int cnt) { var e = _config.Earn; unlim = false; cnt = 0; if (!e.Enable) return 0; bool[] on = { e.EnableGather, e.EnableKill, e.EnableRecycle, e.EnableOnline }; double[] unit = { e.GoldPerGatherUnit, e.GoldPerPlayerKill, e.GoldPerRecycleItem, e.GoldPerOnlineMinute }; double[] cap = { e.GatherDailyCap, e.KillDailyCap, e.RecycleDailyCap, e.OnlineDailyCap }; double sum = 0; for (int i = 0; i < 4; i++) { if (!on[i] || unit[i] <= 0) continue; cnt++; if (cap[i] <= 0) unlim = true; else sum += cap[i]; } return sum; } /// 一天理论最高收益(有任意一项不限上限时换口径说) private string DailyMaxText() { bool unlim; int cnt; double sum = DailyEarnCap(out unlim, out cnt); if (cnt == 0) return "现在没有任何赚币途径。"; if (unlim) return $"一天能赚很多 —— 至少 {Money(sum)} {_config.Settings.GoldSymbol}(其中一部分不限上限)。"; return $"一天全打满 = 最多 {Money(sum)} {_config.Settings.GoldSymbol}(每天 0 点重置)。"; } // 「挂机多久能拿满在线工资」—— 用户原话就是问「在家多久奖励多少」 private string OnlineHoursText() { var e = _config.Earn; if (!e.EnableOnline || e.GoldPerOnlineMinute <= 0 || e.OnlineDailyCap <= 0) return " 在线工资的时长按秒累计,下线不清零。"; double hours = e.OnlineDailyCap / e.GoldPerOnlineMinute / 60.0; return $" 其中在线那份要挂够约 {hours:0.#} 小时才拿满。"; } private string TipHelpText() { if (!_config.Settings.EarnChatNotify) return "每天 0 点自动重置「今日」统计,隔天继续赚。"; if (_config.Settings.EarnChatNotifyMin > 0) return $"每天 0 点重置;赚满 {Money(_config.Settings.EarnChatNotifyMin)} 会在聊天框提示一句。"; return "每天 0 点重置;每一笔收益都会在聊天框提示。"; } // ================= ⑦ 使用说明 ================= private void BuildHelp(CuiElementContainer c, BasePlayer player, PlayerWallet w, float s) { Head(c, "使 用 说 明", $"EcoCenter v{Version} by 薯片 · 原创自研,已获授权使用", s); string[][] cols = { EarnHelpCol(), new[] { "【怎么花】", "· 存着看榜单也行,服务器不会清零。", "· 商城上线后:在商城买东西走经济中心扣钱,", " 收支明细里会写明「在商城买了什么」。", "", "【怎么充值】", "① 到「充值签到」页,扫微信收款码付款", "② 付款备注写【你的游戏名】", "③ 把截图发给服主,钻石马上到账", "④ 或者找服主要兑换码,聊天框输入:", "  /ecode 你的兑换码", "", "【常用命令】", "/bank  打开这个面板", "/gold /diamond  看余额", "/pay <玩家> <数量>  给对方转金币", }, }; float colW = (BodyX1 - BodyX0 - 16f) / 2f; for (int ci = 0; ci < cols.Length; ci++) { float cx = BodyX0 + ci * (colW + 16f); Card(c, PanelName, cx, BodyY0 + 40f, cx + colW, 494f, s); var lines = cols[ci]; for (int i = 0; i < lines.Length; i++) { float y1 = 482f - i * 23f; if (y1 < BodyY0 + 52f) break; bool isHead = lines[i].StartsWith("【"); c.Add(MkLabel(lines[i], cx + 14f, y1 - 21f, cx + colW - 14f, y1, 11, TextAnchor.MiddleLeft, isHead ? Th("accent", ColAccent) : ColText, s), PanelName); } } Foot(c, "数据存在 oxide/data/EcoCenter/ 里,换地图 / 换存档**不会**清空(要清档手动删这个文件夹);" + "颜色、单价、上限都在 oxide/config/EcoCenter.json 里改", s); } #endregion #region ===== 面板:按钮回调 ===== private BasePlayer UiPlayer(ConsoleSystem.Arg arg) { var p = arg == null ? null : arg.Player(); return (p != null && p.IsConnected) ? p : null; } // ★ 只查在线列表(activePlayerList 是本插件已在用的、100% 存在的静态字段) private static BasePlayer FindOnline(ulong uid) { // ★ 用 foreach —— activePlayerList 是 ListHashSet,别赌它有索引器 foreach (var bp in BasePlayer.activePlayerList) if (bp != null && bp.userID == uid) return bp; return null; } [ConsoleCommand("ecocenter.ui.tab")] private void CcUiTab(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; _lastUiClick = $"{DateTime.Now:HH:mm:ss} 切页签 {arg.GetString(0, "?")}({DN(p)})"; ShowPanel(p, arg.GetInt(0, TBank)); } [ConsoleCommand("ecocenter.ui.close")] private void CcUiClose(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p != null) ClosePanel(p); } [ConsoleCommand("ecocenter.ui.refresh")] private void CcUiRefresh(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; ShowPanel(p, arg.GetInt(0, TBank)); } // 收支页:切币种 / 翻页(delta=0 只切币种,不动页码) [ConsoleCommand("ecocenter.ui.flow")] private void CcUiFlow(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; string cur = NormCur(arg.GetString(0, CurGold)) ?? CurGold; int delta = arg.GetInt(1, 0); int page; if (!_flowPage.TryGetValue(p.userID, out page)) page = 0; page += delta; if (page < 0) page = 0; if (delta != 0) _flowPage[p.userID] = page; _flowCur[p.userID] = cur; ShowPanel(p, TFlow); } // 排行榜:切币种 / 翻页(delta=0 只切币种并把页码归零) [ConsoleCommand("ecocenter.ui.top")] private void CcUiTop(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; string cur = NormCur(arg.GetString(0, CurGold)) ?? CurGold; int delta = arg.GetInt(1, 0); if (_topCur.TryGetValue(p.userID, out var old) && old != cur) delta = 0; int page; if (!_topPage.TryGetValue(p.userID, out page)) page = 0; page += delta; if (page < 0) page = 0; _topPage[p.userID] = page; _topCur[p.userID] = cur; ShowPanel(p, TRank); } [ConsoleCommand("ecocenter.ui.sign")] private void CcUiSign(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; if (!EnsureAuthorized(p)) { SendReply(p, "本插件尚未授权,暂时不能领取签到,请联系服主。"); return; } var err = ClaimSignIn(p); if (err != null) SendReply(p, err); else SendReply(p, "签到成功,奖励已经进钱包了。"); ShowPanel(p, TPay); } // 管理后台:在线玩家列表翻页 [ConsoleCommand("ecocenter.ui.admpage")] private void CcUiAdmPage(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null || !IsAdmin(p)) return; int delta = arg.GetInt(0, 0); int page; if (!_admPage.TryGetValue(p.userID, out page)) page = 0; page += delta; if (page < 0) page = 0; _admPage[p.userID] = page; ShowPanel(p, TAdm); } // 管理后台:一键给某玩家发/扣(金额固定档,任意额度走 ecocenter.give) [ConsoleCommand("ecocenter.ui.adm")] private void CcUiAdm(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; if (!IsAdmin(p)) { SendReply(p, "你没有管理权限。"); return; } if (!EnsureAuthorized(p)) { SendReply(p, "本插件尚未授权,后台发放不可用。"); return; } ulong uid; if (!ulong.TryParse(arg.GetString(0, "0"), out uid) || uid == 0) return; string cur = NormCur(arg.GetString(1, CurGold)) ?? CurGold; double amount; if (!double.TryParse(arg.GetString(2, "0"), NumberStyles.Float, CultureInfo.InvariantCulture, out amount) || amount == 0) return; bool plus = amount > 0; double amt = Math.Abs(amount); var target = FindOnline(uid); string tn = target != null ? DN(target) : (NameOf(uid) ?? uid.ToString()); bool ok = plus ? Add(uid, cur, amt, "管理员发放", DN(p) + " 发放") : Take(uid, cur, amt, "管理员扣除", DN(p) + " 扣除"); if (ok) { SendReply(p, $"已给 {tn} {(plus ? "发放" : "扣除")} {Money(amt)} {CurSymbol(cur)}"); if (target != null && target.userID != p.userID) SendReply(target, plus ? $"管理员给你发放了 {Money(amt)} {CurSymbol(cur)}" : $"管理员扣除了你 {Money(amt)} {CurSymbol(cur)}"); } else SendReply(p, "操作失败:可能余额不足、撞到上限、或者数量非法。"); ShowPanel(p, TAdm); } // 素材图自查:看一眼图片库认到几张、缺哪张 [ConsoleCommand("ecocenter.assets")] private void CcAssets(ConsoleSystem.Arg arg) { var p = arg == null ? null : arg.Player(); var lines = new List(); lines.Add($"素材文件夹:{AssetDirPath()}"); lines.Add($"图片库(ImageLibrary):{(ImageLibrary == null ? "未安装 → 只影响素材图,面板照常能用" : "已加载")}"); lines.Add(""); lines.Add("文件名           状态"); lines.Add("-------------------------------------------"); int ok = 0, miss = 0; foreach (var file in AllAssetFiles()) { string path = Path.Combine(AssetDirPath(), file); bool exists = File.Exists(path); string reg = exists ? (GetSprite(KeyOf(file)) == null ? "已放,未注册成功" : "✔ 已注册") : "缺图(会用纯色/内置贴图)"; if (exists && GetSprite(KeyOf(file)) != null) ok++; else miss++; lines.Add(file.PadRight(28, ' ') + " " + reg); } lines.Add(""); lines.Add($"共 {ok} 张可用 / {miss} 张缺失。补图后执行:oxide.reload EcoCenter"); if (p != null) { SendReply(p, $"素材图:{ok} 张可用,{miss} 张缺失。"); foreach (var l in lines.Take(6)) SendReply(p, l); SendReply(p, "完整清单看服务器控制台。"); } Puts("============================================"); Puts("素材图自查"); foreach (var l in lines) Puts(" " + l); Puts("============================================"); RegisterImages(); } #endregion // ==== [APPEND-ANCHOR] ==== } }