/* * VipBalloon.cs —— VIP 头衔气球 * * 干什么:给 VIP 玩家的后脑勺挂一个气球,气球上直接写「VIP 5」这类等级文字。 * * 三个核心问题的处理(都是实测验证过的写法): * 1. 气球会被打爆? → 三重保险:删碰撞体(打不到)+ 免疫伤害(炸不掉)+ 死了自动重建 * 2. 文字被别人改? → 删碰撞体后玩家根本没法交互(射线打不中它),改不了 * 3. 有多少个气球? → 7 种造型。但只有「对话框气泡」是为写字设计的, * 所以默认全用气泡、靠【颜色+文字】区分等级,最稳。 * * 性能:只在玩家上线/下线时创建销毁,不进任何 tick(★ 服管铁律:不碰 OnServerTick)。 * 两个定时器:① 跟随定时器 0.1s(只遍历自己维护的气球表,逐个摆位 + 推坐标); * ② 体检定时器 5s(只查自己的表,掉了就补)。 * 成本 = O(在线 VIP 人数),10 人服 ≈ 每秒 100 次坐标写入,可忽略。 * * v1.0.1(2026-09-21)真机修复: * · 修 NullReferenceException 刷屏:实体半销毁状态下 entity.net 是 null, * 原来只判了 entity == null,取 .ID 就炸。现在一路判到 net。 * · 修位置:OffsetY 0.45 -> 0.30、OffsetZ 0 -> -0.22; * (★ 这里 -0.22 是错的,v1.0.2 真机实测正负相反,最终验收值 = +0.22) * 并改成「先 SetParent 再设 localPosition」,否则位置会被挂载覆盖。 * · 新增 Scale(默认 1.6)放大气球,不然头上的字看不清。 * * ★ v1.2.0(2026-09-21)售卖版 —— 加授权锁 + 权限 + 售前终检修正。 * · 新增【插件授权码】软锁(与《_插件产品化规范与售卖流程.md》§2.4 同一套): * - 未授权时插件照常加载、命令照常注册(服主能自救),但【不挂任何气球】; * - 三个填码入口:config「授权 Authorize → 插件授权码 LicenseCode」/ * 控制台 vipballoon.auth <码> / 聊天 /vipballoon auth <码>; * - vipballoon.deauth 注销;每 6 小时常驻复核(作者停用最长 6h 生效); * - 离线宽限 3 天,且只在「连不上授权服务器」时才启用, * 服务端明确拒绝时一秒宽限都不给。 * · 新增权限 vipballoon.admin(原来硬判 player.IsAdmin,服主没法把开关交给副管理)。 * · DebugLog 默认改 false(售卖版默认安静,要排查再开)。 * · SaveConfig 改缩进写入(Config.WriteObject(_config, true)), * 买家打开的 config 才是可读的多行 JSON,而不是一整行。 * · /vipballoon off 现在是真的关掉(以前只摘掉当前气球,体检/重连会自己挂回来)。 * · 档位表被清空 / ManualLevels 被删时不再崩溃(补 null 兜底)。 * · 新增 vipballoon.check(控制台):自检你服务器上气球 prefab 到底能不能生成。 * * ★ v1.1.0(2026-09-21)真机验收通过 —— 首个可用版。 * 用户截图确认:气球正确挂在头顶正上方、文字「VIP 5」清晰可读。 * 验收通过参数:OffsetX 0 / OffsetY -10.75 / OffsetZ 0.22 / Scale 1.6 / FontScale 2.0 * HeadHeight 1.50 / FollowMode true / FollowInterval 0.1 / CheckIntervalSeconds 5 * * v1.0.9(2026-09-21)位置再降 10 个单位: * · OffsetY -0.75 → -10.75(应用户要求"整体向下 10 字符")。 * 这条数据说明气球模型的球体相对锚点吊得非常高,锚点必须压到模型下方很远。 * * v1.0.8(2026-09-21)位置继续压低: * · 气球是【吊在绳子上】的模型,球体中心在锚点上方 0.5~1.0m, * 所以锚点必须低于头部,球体才落在后脑勺。 * OffsetY -0.10 → -0.75、HeadHeight 1.62 → 1.50(0.30 和 -0.10 实测都偏高)。 * * v1.0.7(2026-09-21)位置/存活/日志三项修正: * · ★★★ `EnableSaving` 是【方法】不是字段(元数据:public void EnableSaving(bool))。 * 原来 `balloon.enableSaving = false` 语义不对 → 实体在存档流程里被回收。 * 日志里两次气球消失都紧跟 `Saving complete`,时间点吻合。改用 EnableSaving(false)。 * 并把体检间隔 30s → 5s,万一再被回收也能秒补回来(观感上不断档)。 * · 位置偏高:OffsetY 0.30 → -0.10(气球模型自身还会往上长,0.30 实测偏高)。 * · 防打爆日志从逐条改成每 50 次汇总(气球在头顶会被持续命中,逐条会刷爆控制台)。 * 另把气球血量拉满做兜底。 * * v1.0.6(2026-09-21)★★★ 修「气球一出生就死」的死循环(真正根因): * · 原代码 `CreateEntity(prefab, Vector3.zero, ...)` —— 在 (0,0,0) 出生。 * 程序化地图里 (0,0,0) 是地图外/无效区 → 服务端立刻判 `Invalid Position` * 并销毁实体(日志里大量 `Invalid Position: xxx (destroying)` 就是这个机制) * → 触发 OnEntityDeath → 插件重建 → 又在原点出生 → 无限循环。 * 现象:日志刷「挂上→被打爆→重建」,屏幕上一个气球都看不到。 * · 修法:**创建时就传真实头部坐标**(GetHeadWorldPos),不在原点出生; * 删 Collider 的时机挪到出生并摆位之后。 * * v1.0.5(2026-09-21)修正位置推送方法(v1.0.4 气球消失的真因): * · 世界实体改变坐标后必须用 `SendNetworkUpdate_Position()`, * 而不是 `SendNetworkUpdate()`(后者不带坐标)→ v1.0.4 因此气球不显示。 * · 已核对:ProtoBuf.Entity 字段 = { position, rotation, parent }, * 世界实体位移【能】同步;而 ParentInfo = { uid, bone } 无偏移字段, * 所以「挂父子 + 设 localPosition」这条路依然不通。 * · 首次挂载即摆位 + 立即推送;修 Detach(null) 清不掉气球的问题。 * * v1.0.4(2026-09-21)★ 换掉位置控制机制(元数据证实根因): * · ProtoBuf.ParentInfo 只有 { uid, bone } 两个字段,**协议层没有偏移量**。 * → SetParent 到玩家身上后,localPosition 客户端不认,气球按客户端规则摆(跟绳飘)。 * 这就是「调了好几次没变化」的唯一原因,不是偏移值写错。 * · 默认改为 FollowMode:不挂父子,由定时器(0.1s)把气球摆到头部坐标。 * 位置 = 玩家坐标 + 身高补偿 + 按朝向旋转的本地偏移 → 偏移这才真正生效。 * * v1.0.3(2026-09-21)真机三轮 —— 位置失控原因排查(结论已被 v1.0.4 取代): * · ★★ 气球 prefab 自带 FakePhysicsRope(假物理绳),球体被绳子拽到末端, * 我们只移动了绳子根节点 → 所以之前怎么调 OffsetZ 都「没变化」。 * 解法:Destroy 掉 FakePhysicsRope + 关掉 LineRenderer(CutRope = true)。 * · 新增 FontScale(默认 2.0)放大字体: * 唯一入口是 PartyBalloon.fontSizeDivider(越小字越大), * 按官方基准 100 反算,2 倍 → 50。 * * v1.0.2(2026-09-21)真机二次校正: * · ★ 轴向判错了。真机实测 OffsetZ = -0.22 时气球跑到【脸正前方】, * 说明玩家本地空间是 **+Z 在后、-Z 在前**(与直觉相反)。 * OffsetZ 改为 +0.22,配置注释一并改成实测结论。 * ★ 教训:本地空间轴向不要靠推理,一定要真机看一次再定正负。 */ using System; using System.Collections.Generic; using UnityEngine; using Oxide.Core; using Oxide.Core.Configuration; using Oxide.Core.Libraries; // webrequest 的 RequestMethod 枚举在这里(真机编译报过 name does not exist) using Newtonsoft.Json; // 授权模块序列化用 namespace Oxide.Plugins { [Info("VIP头衔气球", "薯片", "1.2.0")] [Description("VIP 玩家头顶挂气球显示等级,防打爆、防篡改、掉线自动清理")] public class VipBalloon : RustPlugin { #region 配置 private class TierConfig { public int MinLevel = 1; // 最低等级(含) public int MaxLevel = 3; // 最高等级(含) public string Shape = "speechbubble"; // 造型关键字 public string BalloonColor = "#FF6B2C"; public string TextColor = "#FFFFFF"; } private class PluginConfig { public string BoneName = "head"; // 挂到哪根骨头 // ★★ 坐标是「玩家本地空间」,轴向是【真机实测】出来的,别按直觉猜: // X = 左右(+右 / -左) // Y = 上下(+上 / -下) // Z = 前后(+后 / -前)★ 注意:正值在后,负值在前(和直觉相反!) // 所以「推到后脑勺」要用【正数】Z。 // 这几项可以边跑边改,改完 oxide.reload VipBalloon 即时生效。 // ★★ 高度(最需要按服务器试的一个值) // 加在「头骨高度 HeadHeight」之上,单位≈米。 // 气球是【吊在绳子上】的模型,球体相对锚点吊得非常高, // 所以锚点必须压到人物下方很远,球体才会正好落在头顶。 // -10.75 是实测验收值(2026-09-21,人头正上方一个身位,观感最佳)。 // 偏高就往负调,偏矮就往回调。 public float OffsetY = -10.75f; // ★ 前后:正=往身后,负=往脸前(真机实测,与直觉相反,别写负数) public float OffsetZ = 0.22f; public float OffsetX = 0f; // 左右微调 public float Scale = 1.6f; // 气球整体缩放(模型按人身比例做,1 偏小) // ★ 字体大小:气球文字只有一个入口 fontSizeDivider,它越小字越大。 // 这里用倍数表示,2.0 = 在官方原大小基础上放大两倍。 public float FontScale = 2.0f; // ★★ 位置控制方式(重要) // 协议层事实:ProtoBuf.ParentInfo 只有 { uid, bone },没有偏移量字段。 // → 把气球 SetParent 到玩家身上,服务端设的 localPosition 客户端根本不认, // 气球会被客户端按自己的规则摆(跟着绳子飘)→ 这就是"怎么调都没变化"的原因。 // · true = 不挂父子,由插件自己定时器摆位置(推荐,位移真正生效) // · false = 老办法挂到骨骼上(偏移基本无效,留着以防以后协议变了) public bool FollowMode = true; // 跟随刷新间隔(秒)。0.1 = 每秒 10 次,肉眼很跟手且开销极低。 public float FollowInterval = 0.1f; // 旧版剪绳开关(FollowMode=false 时才用得上) public bool CutRope = true; // 体检间隔。气球万一被回收,靠它补回来;调小=中断更短、开销略增。 public float CheckIntervalSeconds = 5f; public string TextFormat = "VIP {0}"; // 文字模板,{0}=等级 public bool DebugLog = false; // 售卖版默认安静;排查时改 true 再 oxide.reload VipBalloon public List Tiers = new List(); // VIP 等级来源:这里先留一个「手动设置」的兜底, // 接你现有授权系统时把 GetVipLevel 换掉即可。 public Dictionary ManualLevels = new Dictionary(); [JsonProperty("授权 Authorize")] public AuthData Authorize = new AuthData(); } // 授权配置。授权码留空 = 未授权(插件照常加载,但不挂任何气球)。 private class AuthData { [JsonProperty("插件授权码 LicenseCode")] public string LicenseCode = ""; [JsonProperty("离线宽限天数 OfflineGraceDays")] public int OfflineGraceDays = 3; // 只在「连不上授权服务器」时才启用 [JsonProperty("启动时检查新版本 CheckUpdate")] public bool CheckUpdate = true; } private PluginConfig _config; protected override void LoadDefaultConfig() { _config = new PluginConfig(); // 默认三档:1-3 / 4-6 / 7-9,10 级归到最高档 _config.Tiers.Add(new TierConfig { MinLevel = 1, MaxLevel = 3, Shape = "speechbubble", BalloonColor = "#FF6B2C", TextColor = "#FFFFFF" }); _config.Tiers.Add(new TierConfig { MinLevel = 4, MaxLevel = 6, Shape = "speechbubble", BalloonColor = "#4A90E2", TextColor = "#FFFFFF" }); _config.Tiers.Add(new TierConfig { MinLevel = 7, MaxLevel = 10, Shape = "speechbubble", BalloonColor = "#F5C518", TextColor = "#1C1D22" }); SaveConfig(); PrintWarning("已生成默认配置:oxide/config/VipBalloon.json(改完 oxide.reload VipBalloon)"); } protected override void LoadConfig() { base.LoadConfig(); try { _config = Config.ReadObject(); if (_config == null) { LoadDefaultConfig(); return; } if (_config.Authorize == null) _config.Authorize = new AuthData(); // 老配置没有这一段 if (_config.Tiers == null || _config.Tiers.Count == 0) { PrintWarning("配置里没有档位,已补回默认三档。"); _config.Tiers = new List { new TierConfig { MinLevel = 1, MaxLevel = 3, Shape = "speechbubble", BalloonColor = "#FF6B2C", TextColor = "#FFFFFF" }, new TierConfig { MinLevel = 4, MaxLevel = 6, Shape = "speechbubble", BalloonColor = "#4A90E2", TextColor = "#FFFFFF" }, new TierConfig { MinLevel = 7, MaxLevel = 10, Shape = "speechbubble", BalloonColor = "#F5C518", TextColor = "#1C1D22" }, }; } } catch (Exception e) { PrintError("读配置失败(已用默认值启动,原文件没动):" + e.Message); LoadDefaultConfig(); } } protected override void SaveConfig() { Config.WriteObject(_config, true); // ★ true = 缩进写入,买家打开的 config 才是多行可读的 } #endregion #region 授权常量 private const string AuthApiBase = "http://lic.biubr.com"; // 授权服务地址 private const string AuthPluginId = "vipballoon"; // 必须与后台插件清单的 plugin_id 一致 private const string AuthCacheFile = "VipBalloon.auth"; // -> oxide/data/VipBalloon.auth.json private const float AuthRetrySeconds = 300f; // 未授权时每 5 分钟自动重试 private const float AuthRenewHours = 6f; // ★ 常驻复核间隔(撤销最长 6h 生效) private const float AuthNudgeSeconds = 600f; // 未授权时往控制台写提醒的间隔 private const string PermAdmin = "vipballoon.admin"; #endregion #region 造型表 // 7 种造型(birthday_balloons_2025 家族)。 // ★ 只有 speechbubble(对话框气泡)是官方为「写字」设计的; // 其它形状能不能正常显字需要真机试,所以默认全用 speechbubble。 private const string DIR = "assets/prefabs/misc/birthday_balloons_2025/"; private static readonly Dictionary Shapes = new Dictionary { { "speechbubble", DIR + "speechbubble_balloon.deployed.prefab" }, { "heart", DIR + "heart_balloon.deployed.prefab" }, { "star", DIR + "star_balloon.deployed.prefab" }, { "circle", DIR + "circle_balloon.deployed.prefab" }, { "latex", DIR + "latex_balloon.deployed.prefab" }, { "latexclump", DIR + "latexclump_balloon.deployed.prefab" }, { "mixedclump", DIR + "mixedclump_balloon.deployed.prefab" }, }; #endregion #region 运行时状态 // 玩家 → 他的气球(只存这一张表,别的全靠气球自身状态判断) private readonly Dictionary _balloons = new Dictionary(); // 气球 netId → 归属玩家(用来在死亡/受伤事件里反查) private readonly Dictionary _owner = new Dictionary(); private Timer _checkTimer; private Timer _followTimer; // ---- 授权运行时状态(v1.2.0)---- private bool _unloaded; // 已卸载,异步回调直接丢弃 private bool _authorized; // 当前是否已授权(false 时不挂任何气球) private bool _authPending; // 正在联网校验中,避免重复请求 private AuthCache _authCache; // 本地授权缓存 private string _authHint = "尚未校验"; private string _latestVersion = ""; private int _nudgeCount; // /vipballoon off 之后不再自动挂回来(以前 off 只是临时摘掉,体检会补回来) private readonly HashSet _disabled = new HashSet(); #endregion #region 生命周期 private void Init() { LoadConfig(); // 低频体检:只扫自己维护的表,不碰全服实体。默认 30 秒一次。 float interval = _config.CheckIntervalSeconds; if (interval < 5f) interval = 5f; _checkTimer = timer.Every(interval, CheckAll); // ★ 跟随定时器:FollowMode 下气球不挂父子,靠它每 0.1 秒摆一次位置。 // 只遍历自己维护的 _balloons(VIP 数量有限),不扫全服实体。 if (_config.FollowMode) { float fi = _config.FollowInterval; if (fi < 0.05f) fi = 0.05f; _followTimer = timer.Every(fi, FollowAll); } } private void Unload() { _unloaded = true; // 异步回调(联网校验 / 版本查询)到此直接丢弃 // ★ 和 TCBuild 同一个教训:插件走了,东西必须收干净, // 不然气球会永远赖在玩家头上。 _checkTimer?.Destroy(); _followTimer?.Destroy(); int n = 0; foreach (var kv in _balloons) { if (kv.Value != null && !kv.Value.IsDestroyed) { kv.Value.Kill(BaseNetworkable.DestroyMode.None); n++; } } _balloons.Clear(); _owner.Clear(); Puts($"卸载清理完成:已收走 {n} 个气球。"); } private void OnServerInitialized() { permission.RegisterPermission(PermAdmin, this); // ★ 授权:先判授权,再挂气球(未授权时 TryAttach 会直接return,一个气球都不挂) InitAuthorize(); foreach (var p in BasePlayer.activePlayerList) if (p != null && p.IsConnected) TryAttach(p); } private void OnPlayerConnected(BasePlayer player) { if (player == null) return; TryAttach(player); } private void OnPlayerDisconnected(BasePlayer player, string reason) { if (player == null) return; Detach(player); } #endregion #region 三重保险:打不到 / 炸不掉 / 掉了重建 // 保险①:免疫伤害。爆炸是范围伤害,删了碰撞体也躲不掉,所以必须挡这一层。 // ★ 日志改成计数汇总:气球悬在头顶会被反复命中,逐条打印会把控制台刷爆。 private int _blockedCount; private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) { // ★ 判空必须一路判到 net —— 只判 entity 不够, // 实体正在销毁的半截状态下 entity.net 可能是 null, // 直接取 .ID 就是 NullReferenceException(v1.0.0 线上刷屏的元凶)。 if (entity == null || info == null || entity.net == null) return null; ulong ownerId; if (!_owner.TryGetValue(entity.net.ID.Value, out ownerId)) return null; info.damageTypes.Clear(); info.HitEntity = null; info.PointStart = Vector3.zero; // 每 50 次才打一条,避免刷屏(气球在头顶会被持续命中) _blockedCount++; if (_config.DebugLog && _blockedCount % 50 == 1) Puts($"[防打爆] 累计已挡下 {_blockedCount} 次对 VIP 气球的伤害。"); return true; // 非 null = 覆盖默认行为,伤害作废 } // 保险②:真被销毁了就重建(两条路都监听,哪个先到算哪个) private void OnEntityKill(BaseNetworkable entity) { if (entity == null || entity.net == null) return; ulong ownerId; if (!_owner.TryGetValue(entity.net.ID.Value, out ownerId)) return; _owner.Remove(entity.net.ID.Value); _balloons.Remove(ownerId); BasePlayer p = BasePlayer.FindByID(ownerId); if (p != null && p.IsConnected) { if (_config.DebugLog) Puts($"[重建] {p.displayName} 的气球被回收了(非战斗销毁),正在补一个。"); // 下一帧再建,避免在销毁流程里造实体出问题 timer.Once(0.5f, () => TryAttach(p)); } } private void OnEntityDeath(BaseCombatEntity entity, HitInfo info) { if (entity == null || entity.net == null) return; ulong ownerId; if (!_owner.TryGetValue(entity.net.ID.Value, out ownerId)) return; _owner.Remove(entity.net.ID.Value); _balloons.Remove(ownerId); BasePlayer p = BasePlayer.FindByID(ownerId); if (p != null && p.IsConnected) { if (_config.DebugLog) Puts($"[重建] {p.displayName} 的气球被打爆了,正在补一个。"); timer.Once(0.5f, () => TryAttach(p)); } } // 保险③:低频体检兜底(万一上面两条都没接住) private void CheckAll() { if (_balloons.Count == 0) return; var need = new List(); foreach (var kv in _balloons) { BaseEntity e = kv.Value; if (e == null || e.IsDestroyed) need.Add(kv.Key); } if (need.Count == 0) return; foreach (ulong id in need) { _balloons.Remove(id); BasePlayer p = BasePlayer.FindByID(id); if (p != null && p.IsConnected) { if (_config.DebugLog) Puts($"[体检] {p.displayName} 的气球缺失,补一个。"); TryAttach(p); } } } #endregion #region 挂气球 / 摘气球 private void Detach(BasePlayer player) { if (player == null) return; BaseEntity e; if (!_balloons.TryGetValue(player.userID, out e)) return; if (e != null && !e.IsDestroyed) e.Kill(BaseNetworkable.DestroyMode.None); if (e != null && e.net != null) _owner.Remove(e.net.ID.Value); _balloons.Remove(player.userID); } private void TryAttach(BasePlayer player) { if (player == null || !player.IsConnected) return; if (!_authorized) return; // ★ 软锁:未授权不挂任何气球 int level = GetVipLevel(player); if (level <= 0) { Detach(player); // 不是 VIP 或掉级了 → 摘掉 return; } // 已经有了就只更新文字/颜色,不重复造 BaseEntity exist; if (_balloons.TryGetValue(player.userID, out exist) && exist != null && !exist.IsDestroyed) { ApplyLook(exist, level); return; } TierConfig tier = GetTier(level); string prefab; if (!Shapes.TryGetValue(tier.Shape, out prefab)) prefab = Shapes["speechbubble"]; // ★★★ 关键修复:出生坐标必须传真实位置,不能传 Vector3.zero。 // v1.0.5 及之前一律在 (0,0,0) 出生,而程序化地图里 (0,0,0) 通常是 // 地图外/无效区 → 服务端立刻判 `Invalid Position` 并销毁该实体 // → 触发 OnEntityDeath → 插件重建 → 又在原点出生 → 无限死循环。 // 这就是「气球完全看不到 + 日志刷重建」的真正原因。 Vector3 spawnPos = GetHeadWorldPos(player); BaseEntity balloon = GameManager.server.CreateEntity(prefab, spawnPos, Quaternion.identity, true); if (balloon == null) { PrintError($"气球生成失败,检查你服务器有没有这个内容:{prefab}"); return; } // ★★★ 关键:EnableSaving 是【方法】不是字段(元数据:public void EnableSaving(bool wants))。 // 原来写 `balloon.enableSaving = false` 语义不对,实体仍会被存档流程回收 // —— 日志两次气球消失都紧跟在 `Saving complete` 之后,时间点高度吻合。 balloon.EnableSaving(false); // 不写存档、不被存档回收 balloon.OwnerID = player.userID; // 标记归属,便于识别与排查 balloon.Spawn(); // ★ 删掉碰撞体(时机挪到出生+摆位之后,避免刚出生就掉穿地形被判越界): // 没碰撞体 = 射线打不中 = 打不到它,也点不开它的配置框(改不了字)。 // 一句话同时解决「被打爆」和「被改文字」两个问题。 // 注意:删掉的是【客户端/服务端都有的碰撞体】,位置已在合法坐标,不会再掉出去。 UnityEngine.Object.Destroy(balloon.GetComponent()); UnityEngine.Object.Destroy(balloon.GetComponent()); if (_config.FollowMode) { // ★ 新模式:不进父子链。气球是世界实体,位置由 PlaceAtHead 摆, // 之后每 FollowInterval 秒由 FollowAll 刷新跟上。 // 理由见配置注释:协议层 ParentInfo 没有偏移字段, // 挂上去之后客户端按自己的规则摆,服务端说什么都不算。 PlaceAtHead(balloon, player); // 先摆到位再推送,避免第一帧出现在原点 balloon.SendNetworkUpdateImmediate(); } else { // 旧模式(偏移基本无效,保留以防以后协议改动) balloon.SetParent(player, _config.BoneName, false, true); balloon.transform.localPosition = new Vector3(_config.OffsetX, _config.OffsetY, _config.OffsetZ); if (_config.CutRope) CutRope(balloon); } // 放大:气球按人身比例做,挂脑袋上偏小、字看不清。 balloon.transform.localScale = Vector3.one * _config.Scale; // ★ 让气球更"扛造":把血量拉满,避免任何漏网的伤害把它打没。 // 这只是兜底,主防线仍是 OnEntityTakeDamage 全免 + 无碰撞体。 BaseCombatEntity bce = balloon as BaseCombatEntity; if (bce != null) { bce.InitializeHealth(10000f, 10000f); bce.SetHealth(10000f); } ApplyLook(balloon, level); _balloons[player.userID] = balloon; if (balloon.net != null) _owner[balloon.net.ID.Value] = player.userID; if (_config.DebugLog) Puts($"已给 {player.displayName}(VIP {level})挂上气球:{tier.Shape} / {tier.BalloonColor}"); } private void ApplyLook(BaseEntity balloon, int level) { PartyBalloon pb = balloon.GetComponent(); if (pb == null) return; TierConfig tier = GetTier(level); // ★ 字体放大:只有 fontSizeDivider 一个入口,它越小字越大。 // 官方默认值埋在 prefab 里(服务端读不到),所以没法"读取原值再乘"。 // 这里用「以当前值为基准、每次重设前先归一」的做法不成立, // 改成:直接按倍数反算 —— 官方基准值取 prefab 里的常见值 100f。 // FontScale=2 即 fontSizeDivider=50 → 字大一倍。 if (_config.FontScale > 0f) { float baseDivider = 100f; pb.fontSizeDivider = baseDivider / _config.FontScale; } // ★ 文字:服务端直接写,不用生成任何图片 pb.SetBalloonText(string.Format(_config.TextFormat, level)); pb.SetBalloonColour(HexToColor(tier.BalloonColor, Color.white)); pb.SetTextColour(HexToColor(tier.TextColor, Color.white)); balloon.SendNetworkUpdateImmediate(); } // ★★ 跟随:把气球摆到玩家头部位置(FollowMode 专用) // 玩家头部世界坐标 = 玩家坐标 + 按朝向旋转后的本地偏移 + 抬高。 private void FollowAll() { if (_balloons.Count == 0) return; // 边遍历边可能摘人,先用快照避免改集合 var ids = new List(_balloons.Keys); foreach (ulong id in ids) { BaseEntity balloon; if (!_balloons.TryGetValue(id, out balloon)) continue; if (balloon == null || balloon.IsDestroyed) { _balloons.Remove(id); continue; } BasePlayer p = BasePlayer.FindByID(id); if (p == null || !p.IsConnected) { // ★ 人已经不在线了,得直接清这个气球(Detach(null) 会直接 return,清不掉) if (balloon != null && !balloon.IsDestroyed) balloon.Kill(BaseNetworkable.DestroyMode.None); if (balloon != null && balloon.net != null) _owner.Remove(balloon.net.ID.Value); _balloons.Remove(id); continue; } PlaceAtHead(balloon, p); } } // ★ 算「气球应该在的世界坐标」——创建时和跟随时共用同一套算法, // 保证实体一出生就在正确位置,不需要从别处飞过来。 private Vector3 GetHeadWorldPos(BasePlayer player) { if (player == null) return Vector3.zero; // 玩家朝向的平面旋转 Vector3 fwd = player.eyes != null ? player.eyes.rotation * Vector3.forward : player.transform.forward; fwd.y = 0f; if (fwd.sqrMagnitude < 0.001f) fwd = Vector3.forward; fwd.Normalize(); Vector3 right = Vector3.Cross(Vector3.up, fwd).normalized; // 头部基准点:玩家坐标 + 站立高度的头部补偿 Vector3 head = player.transform.position + Vector3.up * HeadHeight; // 本地偏移换算到世界:X 右、Y 上、Z 沿朝向 return head + right * _config.OffsetX + Vector3.up * _config.OffsetY + fwd * _config.OffsetZ; } // 把气球摆到玩家后脑勺 private void PlaceAtHead(BaseEntity balloon, BasePlayer player) { if (balloon == null || player == null) return; Vector3 world = GetHeadWorldPos(player); Vector3 fwd = player.eyes != null ? player.eyes.rotation * Vector3.forward : player.transform.forward; fwd.y = 0f; if (fwd.sqrMagnitude < 0.001f) fwd = Vector3.forward; fwd.Normalize(); balloon.transform.position = world; balloon.transform.rotation = Quaternion.LookRotation(fwd, Vector3.up); // ★★★ 关键:世界实体推位置必须用 SendNetworkUpdate_Position()。 // v1.0.4 用的是 SendNetworkUpdate()(只推"实体变了"、不带坐标), // 结果客户端上的气球留在原地/干脆不显示 → 上机表现为"气球完全不见了"。 // ProtoBuf.Entity 里 position / rotation 字段是有的,所以位置**能**同步, // 前提是用对这个方法。 balloon.SendNetworkUpdate_Position(); } // 站立时脚底到「头骨中心」的大致高度。 // 1.62 是眼睛高度,头骨中心略低,取 1.50 更贴合"挂后脑勺"。 private const float HeadHeight = 1.50f; // ★★ 剪断假物理绳:气球"挂不到头上、被拉出画面"的真正原因。 // FakePhysicsRope 会用 ropeLength 把球体拽到绳长末端; // 服务端挂载只移动了绳子的根节点,球还在绳那头。 // 拆掉组件后球体位置由我们设的 localPosition 说了算。 private void CutRope(BaseEntity balloon) { int cut = 0; // 1) 拆掉假物理绳组件(可能挂在根上,也可能挂在子物体上) var ropes = balloon.GetComponentsInChildren(true); if (ropes != null) { foreach (var r in ropes) { if (r == null) continue; UnityEngine.Object.Destroy(r); cut++; } } // 2) 关掉所有绳子渲染器(LineRenderer),残留的线一并清掉 var lines = balloon.GetComponentsInChildren(true); if (lines != null) { foreach (var l in lines) { if (l == null) continue; l.enabled = false; } } if (_config.DebugLog) Puts($"[剪绳] 已拆掉 {cut} 个假物理绳组件,并关闭全部绳子渲染器。"); } #endregion #region 等级 / 档位 // ★ 接你现有授权系统时,只改这一个方法。 // 目前先用配置里的手动表(key 用玩家 SteamID 字符串)。 private int GetVipLevel(BasePlayer player) { if (player == null) return 0; if (_disabled.Contains(player.userID)) return 0; // /vipballoon off 之后不再自动挂回来 if (_config.ManualLevels == null) return 0; int lv; if (_config.ManualLevels != null && _config.ManualLevels.TryGetValue(player.UserIDString, out lv)) return lv; return 0; // 0 = 不是 VIP } private TierConfig GetTier(int level) { // ★ 买家把档位表清空也不许崩(原来直接取 Tiers[Count-1] 会越界刷红字) if (_config.Tiers == null || _config.Tiers.Count == 0) return new TierConfig(); for (int i = 0; i < _config.Tiers.Count; i++) { TierConfig t = _config.Tiers[i]; if (t != null && level >= t.MinLevel && level <= t.MaxLevel) return t; } return _config.Tiers[_config.Tiers.Count - 1]; } private static Color HexToColor(string hex, Color fallback) { Color c; if (ColorUtility.TryParseHtmlString(hex, out c)) return c; return fallback; } #endregion #region 命令(调试用) [ChatCommand("vipballoon")] private void CmdVipBalloon(BasePlayer player, string command, string[] args) { if (player == null) return; // ★ 授权码入口:未授权时也必须能用(这是买家的自救通道) if (args != null && args.Length > 0 && args[0].ToLowerInvariant() == "auth") { if (!IsAdmin(player)) { SendReply(player, "只有服务器管理员能填授权码(这是本插件的激活入口)。"); return; } string code = args.Length > 1 ? args[1].Trim() : ""; if (string.IsNullOrEmpty(code)) { SendReply(player, "当前授权状态:" + _authHint + " 授权码 " + MaskCode(CurrentCode()) + " (用法:/vipballoon auth PL-XXXX-XXXX-XXXX)"); return; } if (_config.Authorize == null) _config.Authorize = new AuthData(); _config.Authorize.LicenseCode = code.ToUpperInvariant(); SaveConfig(); SendReply(player, "正在校验授权码,结果会在这里告诉你…"); RequestVerify(code, player, true); return; } if (!IsAdmin(player)) { SendReply(player, "无权限:需要 vipballoon.admin(或服务器管理员)。"); return; } if (args == null || args.Length == 0) { SendReply(player, "用法:/vipballoon set <等级> [玩家名] | off | on | auth <码> | status"); return; } string sub = args[0].ToLower(); if (sub == "status") { SendReply(player, "授权:" + _authHint + " 版本 v" + Version.ToString() + " 当前挂着 " + _balloons.Count + " 个气球"); return; } if (sub == "set" && args.Length >= 2) { if (!EnsureAuthorized(player)) { SendReply(player, "插件未授权,功能不可用。激活:/vipballoon auth <码>"); return; } int lv; if (!int.TryParse(args[1], out lv)) { SendReply(player, "等级要填数字。"); return; } BasePlayer target = player; if (args.Length >= 3) { BasePlayer found = BasePlayer.Find(args[2]); if (found == null) { SendReply(player, "没找到这个玩家:" + args[2]); return; } target = found; } if (_config.ManualLevels == null) _config.ManualLevels = new Dictionary(); _config.ManualLevels[target.UserIDString] = lv; _disabled.Remove(target.userID); SaveConfig(); TryAttach(target); SendReply(player, "已把 " + target.displayName + " 设为 VIP " + lv + "。"); return; } if (sub == "off") { _disabled.Add(player.userID); Detach(player); SendReply(player, "已关掉你的 VIP 气球(不会自动挂回来,用 /vipballoon on 恢复)。"); return; } if (sub == "on") { if (!EnsureAuthorized(player)) { SendReply(player, "插件未授权,功能不可用。激活:/vipballoon auth <码>"); return; } _disabled.Remove(player.userID); TryAttach(player); SendReply(player, "已恢复你的 VIP 气球。"); return; } SendReply(player, "用法:/vipballoon set <等级> [玩家名] | off | on | auth <码> | status"); } private void SendReply(BasePlayer player, string msg) { if (player == null) return; player.ChatMessage(msg); } private bool IsAdmin(BasePlayer player) { if (player == null) return false; return player.IsAdmin || permission.UserHasPermission(player.UserIDString, PermAdmin); } private void Msg(BasePlayer player, string text) { if (player == null) { Puts(text); return; } SendReply(player, "[VIP气球] " + text); } #endregion #region 授权(插件授权码 · 联网校验 · 离线宽限) // ===================================================================== // 【软锁】策略(与《_插件产品化规范与售卖流程.md》§2.4 一致): // · 未授权时插件【照常加载、不报错、不卸载】,命令照常注册 —— // 这样服主能直接用 vipballoon.auth <码> 自救,不会卡在"卸载了就没法激活"的死循环。 // · 只在「功能入口」拦一道:TryAttach 直接 return,所以一个气球都不会出现。 // · 联网校验走 webrequest 异步回调,绝不阻塞服务器主线程。 // · 离线宽限 3 天:作者服务器挂了 / 买家断网,不能把买家的服搞停。 // · 一个授权码能绑几台服务器由授权服务说了算,插件只负责把拒绝原因翻成中文。 // ===================================================================== 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 = ""; } #pragma warning disable 0649 // 下面这几个字段由 JSON 反序列化(反射)赋值,编译器看不到,属正常 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; } #pragma warning restore 0649 // ★ 离线宽限【硬上限】:配置里写得再多,也最多按 3 天算。 // 否则买家把 config 改成 365 就能离线白用一整年。 private const int AuthMaxGraceDays = 3; private int GraceDays { get { int d = (_config != null && _config.Authorize != null) ? _config.Authorize.OfflineGraceDays : 3; return Mathf.Clamp(d, 0, AuthMaxGraceDays); } } private static bool TryParseUtc(string s, out DateTime t) { t = DateTime.MinValue; if (string.IsNullOrEmpty(s)) return false; return DateTime.TryParse(s, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out t); } private static string MaskCode(string code) { if (string.IsNullOrEmpty(code)) return "(未填写)"; if (code.Length < 12) return code; return code.Substring(0, 7) + "-****-" + code.Substring(code.Length - 4); } private void LoadAuthCache() { try { var f = Interface.Oxide.DataFileSystem.GetFile(AuthCacheFile); _authCache = (f != null && f.Exists()) ? f.ReadObject() : null; } catch (Exception ex) { PrintWarning("授权缓存读取失败(忽略,稍后重新联网校验): " + ex.Message); _authCache = null; } if (_authCache == null) _authCache = new AuthCache(); } private void SaveAuthCache() { try { Interface.Oxide.DataFileSystem.WriteObject(AuthCacheFile, _authCache); } catch (Exception ex) { PrintWarning("授权缓存保存失败: " + ex.Message); } } private bool CacheFresh() { DateTime t; if (_authCache == null) return false; if (_authCache.PluginId != AuthPluginId) return false; if (!TryParseUtc(_authCache.LastOk, out t)) return false; // 配置里写了另一个码 -> 旧凭据立刻作废(换了码不许旧凭据继续生效) string cfgCode = (_config != null && _config.Authorize != null && _config.Authorize.LicenseCode != null) ? _config.Authorize.LicenseCode.Trim().ToUpperInvariant() : ""; string cacheCode = (_authCache.Code != null) ? _authCache.Code.Trim().ToUpperInvariant() : ""; if (cfgCode.Length > 0 && !string.Equals(cfgCode, cacheCode, StringComparison.OrdinalIgnoreCase)) return false; // 防系统时间回拨:当前时间比上次成功校验还早 6h 以上 = 有人在拨表, // 这时【不认宽限】,强制联网复核。 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() : ""; } private void InitAuthorize() { LoadAuthCache(); CheckPluginVersion(); // 只提醒不拦:查后台登记的最新版本 timer.Every(AuthNudgeSeconds, AuthNudge); // 未授权时提醒(只写控制台,最多 3 次) timer.Every(AuthRenewHours * 3600f, AuthRenew); // ★ 常驻复核:撤销最长 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); return; } _authorized = false; if (string.IsNullOrEmpty(CurrentCode())) { _authHint = "未填写授权码"; PrintWarning("本插件尚未授权,不会给任何人挂气球。"); PrintWarning("激活方法:配置文件「授权 Authorize → 插件授权码 LicenseCode」填入 PL- 开头的码,"); PrintWarning("或在服务器控制台执行:vipballoon.auth <你的授权码>"); PrintAuthStatus(); return; // 没码就不必空转重试,等服主填码后重载 } _authHint = "正在联网校验…"; RequestVerify(null, null, false); } private void RetryAuth() { if (_unloaded || _authorized) return; if (string.IsNullOrEmpty(CurrentCode())) return; RequestVerify(null, null, false); } // ★ 常驻复核:已授权状态也必须定期问服务器一次。 // 否则作者在后台「停用」之后,插件靠本地宽限能一直跑到过期 = 撤销不了。 private void AuthRenew() { if (_unloaded) return; if (string.IsNullOrEmpty(CurrentCode())) return; RequestVerify(null, null, false); } private void AuthNudge() { if (_unloaded || _authorized) return; if (_nudgeCount++ >= 3) return; // ★ 只写服务器控制台,不进玩家聊天框(进聊天框看着像服务器报错) Puts("★ 本插件尚未授权,不会挂气球(" + _authHint + ")。" + "激活:控制台执行 vipballoon.auth PL-XXXX-XXXX-XXXX"); } 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, "还没有授权码。用法:vipballoon.auth PL-XXXX-XXXX-XXXX"); return; } if (webrequest == null) { OnAuthFailed(manual, "插件运行环境不支持联网(webrequest 不可用)", false, manualInput, use); return; } // 故意不上报服务器 IP:IP 一律由授权服务按请求来源判定,杜绝伪造 var payload = new Dictionary { { "code", use }, { "plugin_id", AuthPluginId } }; var headers = new Dictionary { { "Content-Type", "application/json" } }; _authPending = true; Puts("[授权] 正在向授权服务器校验(" + MaskCode(use) + ")…"); webrequest.Enqueue(AuthApiBase + "/api/plugin/verify", JsonConvert.SerializeObject(payload), (status, response) => OnVerifyResponse(status, response, use, manual, manualInput), this, RequestMethod.POST, headers, 15f); } private void OnVerifyResponse(int status, string response, string code, BasePlayer manual, bool manualInput) { _authPending = false; if (_unloaded) return; if (status != 200 || string.IsNullOrEmpty(response)) { OnAuthFailed(manual, status == 0 ? "连不上授权服务器(出网被拦 / 服务临时不可用)" : ("授权服务器返回 HTTP " + status), false, manualInput, code); return; } AuthResp r = null; try { r = JsonConvert.DeserializeObject(response); } catch (Exception) { OnAuthFailed(manual, "授权服务器返回内容无法解析", false, manualInput, code); return; } if (r == null || !r.ok) { string why = (r != null && !string.IsNullOrEmpty(r.error)) ? r.error : "授权校验未通过"; OnAuthFailed(manual, why, true, manualInput, code); return; } // ---- 校验通过 ---- bool was = _authorized; _authorized = true; _nudgeCount = 0; _authCache.Code = code; _authCache.PluginId = AuthPluginId; _authCache.LastOk = DateTime.UtcNow.ToString("o", System.Globalization.CultureInfo.InvariantCulture); _authCache.PluginVer = Version.ToString(); if (string.IsNullOrEmpty(_authCache.BoundAt)) _authCache.BoundAt = _authCache.LastOk; if (r.plugin != null && !string.IsNullOrEmpty(r.plugin.version)) _latestVersion = r.plugin.version; SaveAuthCache(); if (!string.IsNullOrEmpty(_latestVersion) && CompareVer(_latestVersion, Version.ToString()) > 0) SetUpdateHint(_latestVersion); _authHint = "已授权"; Puts("[授权] 校验通过,插件已生效。授权码 " + MaskCode(code) + ",本地凭据有效期至 " + GraceExpireText() + "。"); PrintAuthStatus(); if (manualInput && manual != null) Msg(manual, "授权成功,插件已生效。"); if (!was) AttachAllOnline(); // 刚从不授权变授权 -> 给在线的 VIP 补上气球 } // serverRejected=true = 服务端明确拒绝,不接受离线宽限 private void OnAuthFailed(BasePlayer manual, string reason, bool serverRejected, bool manualInput, string attempted) { if (_unloaded) return; // 情形一:这次用的码没通过,但本地还存着另一份仍然有效的凭据 -> 回退到那个码重验 if (serverRejected && CacheFresh() && !string.IsNullOrEmpty(_authCache.Code) && !string.Equals(_authCache.Code, attempted, StringComparison.OrdinalIgnoreCase)) { string good = _authCache.Code; if (_config.Authorize == null) _config.Authorize = new AuthData(); _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(作者停用后宽限立刻失效);网络类失败绝不清凭据。 if (serverRejected && _authCache != null && !string.IsNullOrEmpty(_authCache.LastOk)) { _authCache.LastOk = ""; SaveAuthCache(); Puts("[授权] 已清除本地凭据(服务端拒绝:\"" + reason + "\"),宽限期不再生效。"); } bool was = _authorized; _authorized = false; _authHint = serverRejected ? ("授权失败:" + reason) : ("暂未授权:" + reason); PrintWarning("[授权] 未通过:" + reason); PrintWarning("请确认授权码格式为 PL-XXXX-XXXX-XXXX,且买的是本插件;重新校验:vipballoon.auth <码>。"); if (manual != null) Msg(manual, "授权失败:" + reason); if (was) DetachAll(); // 授权掉了 -> 把已挂的气球全收走,不留残留在玩家头上 timer.Once(AuthRetrySeconds, RetryAuth); } private void PrintAuthStatus() { Puts("===== VIP头衔气球 授权状态 ====="); Puts(" 状态:" + (_authorized ? "已授权(功能正常)" : "未授权(不会挂气球)")); Puts(" 授权码:" + MaskCode(CurrentCode())); Puts(" 插件版本:v" + Version.ToString() + (string.IsNullOrEmpty(_latestVersion) ? "" : ("(后台最新 v" + _latestVersion + ")"))); Puts(" 本地凭据:" + (CacheFresh() ? ("有效,至 " + GraceExpireText()) : "无 / 已过期")); Puts(" 主动复核:每 " + AuthRenewHours + " 小时一次;仅「连不上服务器」时才用 " + GraceDays + " 天宽限"); Puts(" 用法:vipballoon.auth (不带参数 = 只看状态)"); Puts(" vipballoon.deauth(★ 注销:清本地凭据 + 清配置里的码,立刻回到未授权)"); } private static int CompareVer(string a, string b) { if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return 0; string[] pa = a.Split('.'), pb = b.Split('.'); int n = Math.Max(pa.Length, pb.Length); for (int i = 0; i < n; i++) { int x = i < pa.Length ? ParseSeg(pa[i]) : 0; int y = i < pb.Length ? ParseSeg(pb[i]) : 0; if (x != y) return x > y ? 1 : -1; } return 0; } private static int ParseSeg(string s) { int v; return (s != null && int.TryParse(s.Trim(), out v)) ? v : 0; } private void CheckPluginVersion() { if (_unloaded) 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) { PrintWarning("[版本] 后台登记最新版为 v" + latest + ",你当前是 v" + Version.ToString() + "。功能不受影响,方便时更新即可。"); } // ★ 软锁统一入口:返回 true 才继续执行功能 private bool EnsureAuthorized(BasePlayer player) { if (_authorized) return true; if (player != null && IsAdmin(player)) Msg(player, "本插件尚未授权,功能不可用。激活:控制台 vipballoon.auth PL-XXXX-XXXX-XXXX"); return false; } private void AttachAllOnline() { foreach (var p in BasePlayer.activePlayerList) if (p != null && p.IsConnected) TryAttach(p); } private void DetachAll() { var ids = new List(_balloons.Keys); foreach (ulong id in ids) { BaseEntity e; if (_balloons.TryGetValue(id, out e) && e != null && !e.IsDestroyed) e.Kill(BaseNetworkable.DestroyMode.None); if (e != null && e.net != null) _owner.Remove(e.net.ID.Value); _balloons.Remove(id); } _balloons.Clear(); _owner.Clear(); Puts("[授权] 授权失效,已收走全部 VIP 气球(" + ids.Count + " 个)。"); } #endregion #region 控制台命令(授权 / 自检 / 设等级) [ConsoleCommand("vipballoon.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); } [ConsoleCommand("vipballoon.deauth")] private void CcDeauth(ConsoleSystem.Arg arg) { var player = arg != null ? arg.Player() : null; if (player != null && !IsAdmin(player)) { Msg(player, "只有服务器管理员能注销授权。"); return; } _authorized = false; _authHint = "已注销(请重新填授权码)"; _authCache = new AuthCache(); SaveAuthCache(); if (_config != null && _config.Authorize != null) { _config.Authorize.LicenseCode = ""; SaveConfig(); Puts("[授权] 配置里的授权码也已清空。"); } DetachAll(); Puts("[授权] 已注销:本地凭据与配置里的授权码都已清除,插件回到未授权状态。"); Puts(" 重新激活:vipballoon.auth PL-XXXX-XXXX-XXXX"); if (player != null) Msg(player, "已注销,插件回到未授权状态。"); } // ★ 自检:装完先跑一次,一眼看清「你这个服到底能不能用」 [ConsoleCommand("vipballoon.check")] private void CcCheck(ConsoleSystem.Arg arg) { Puts("===== VIP头衔气球 自检 ====="); Puts(" 版本:v" + Version.ToString() + (string.IsNullOrEmpty(_latestVersion) ? "" : ("(后台最新 v" + _latestVersion + ")"))); Puts(" 授权:" + (_authorized ? "已授权,功能正常" : "未授权 —— " + _authHint)); Puts(" 授权码:" + MaskCode(CurrentCode())); Puts(" 本地凭据:" + (CacheFresh() ? ("有效,至 " + GraceExpireText()) : "无 / 已过期")); Puts(" ---- 气球造型可用性(★ 全不可用说明服务器没有生日气球内容)----"); foreach (var kv in Shapes) { GameObject go = GameManager.server.FindPrefab(kv.Value); Puts(" " + kv.Key.PadRight(12) + (go != null ? "可用" : "★ 缺 prefab")); } Puts(" 当前挂着:" + _balloons.Count + " 个气球 | 手动关闭:" + _disabled.Count + " 人"); Puts(" ---- 常用命令 ----"); Puts(" vipballoon.auth PL-XXXX-XXXX-XXXX 填授权码"); Puts(" vipballoon.check 再看一次这份自检"); Puts(" oxide.reload VipBalloon 改完配置重载"); } [ConsoleCommand("vipballoon.set")] private void CcSet(ConsoleSystem.Arg arg) { if (arg == null || arg.Args == null || arg.Args.Length < 2) { Puts("用法:vipballoon.set <玩家名或SteamID> <等级> (等级填 0 = 取消 VIP)"); return; } string who = arg.GetString(0); int lv = arg.GetInt(1); ulong id; if (!ulong.TryParse(who, out id)) { var p = BasePlayer.Find(who); if (p == null) { Puts("没找到在线玩家:" + who + "(SteamID 可以直接离线设置)"); return; } id = p.userID; } if (_config.ManualLevels == null) _config.ManualLevels = new Dictionary(); _config.ManualLevels[id.ToString()] = lv; _disabled.Remove(id); SaveConfig(); var target = BasePlayer.FindByID(id); if (target != null) { if (lv <= 0) Detach(target); else TryAttach(target); Puts("已把 " + target.displayName + " 设为 VIP " + lv + "。"); } else { Puts("已记录(该玩家不在线,上线时生效):" + id + " -> VIP " + lv); } } #endregion } }