// NDLOCAL[TOP 2026-09-03 setflag] Rust 9月强更(25083359)删 BaseEntity.SetFlag→末尾唯一namespace加shim复刻+顶部using(避免跨插件歧义)。升级覆盖须重打。 using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Plugins; using Oxide.Core.Libraries.Covalence; using Oxide.Game.Rust.Cui; using System; using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Text; using UnityEngine; using UnityEngine.Networking; namespace Oxide.Plugins { using NDShim_SmartSign; [Info("SmartSign", "RustXZ", "0.7.4")] [Description("智能贴图 · 多服共享贴图平台客户端:/img 全屏 web-mirror CUI(素材库/霓虹工作台/相册/广场/详情/E接管),选图贴牌、社交、配额。零配置(内置共享密钥)。")] internal class SmartSign : RustPlugin { // ======================= 配置 ======================= private Configuration config; private class Configuration { [JsonProperty("平台 API 基址")] public string ApiBase { get; set; } = "https://ss.rustxz.com"; [JsonProperty("内置共享密钥(SMARTSIGN_PLUGIN_SECRET,与平台 ecosystem 同值)")] public string PluginSecret { get; set; } = "c4xTMJYZuA3weGQSprGUssg0r0kbaMl9"; [JsonProperty("HTTP 超时秒")] public int TimeoutSeconds { get; set; } = 15; [JsonProperty("服务器标识 server_key(留空=用 公网IP:端口)")] public string ServerKey { get; set; } = ""; [JsonProperty("仅授权玩家可用(默认 false=零配置全员可 /img,封禁由平台 /user 拦)")] public bool RequirePermission { get; set; } = false; [JsonProperty("贴牌射线最大距离(米)")] public float MaxDistance { get; set; } = 5f; [JsonProperty("远程下架轮询间隔(秒)")] public float PollSeconds { get; set; } = 60f; [JsonProperty("调试日志")] public bool Debug { get; set; } = false; // —— 统一通知:所有插件通知同时走 提示音 + 公屏聊天 + NCP 气泡 —— [JsonProperty("通知·公屏聊天")] public bool NotifyChat { get; set; } = true; [JsonProperty("通知·提示音")] public bool NotifySound { get; set; } = true; [JsonProperty("通知·提示音 prefab")] public string SoundEffect { get; set; } = "assets/bundled/prefabs/fx/invite_notice.prefab"; // —— F6:气泡通道(自适应逻辑不变,仅给服主显式开关)—— // ncp=装了 NCP 走气泡、没装自动降级 gametip(默认);gametip=强制 gametip 横幅;off=不弹气泡 [JsonProperty("气泡通道(ncp|gametip|off)")] public string BubbleChannel { get; set; } = "ncp"; // —— F5:文本输入方式(相册名/邮箱/验证码/评论/举报理由)—— [JsonProperty("文本输入用CUI输入框(false=聊天捕获旧路径)")] public bool UseInputField { get; set; } = true; // —— F7/F16:贴图后抑制原生木牌编辑器的方案 —— // lock=贴图即锁牌 SetFlag(Locked)(默认);hook=CanUpdateSign 拦 painted 牌;off=不抑制 [JsonProperty("原生编辑抑制方案(lock|hook|off)")] public string SignEditSuppress { get; set; } = "lock"; } protected override void LoadDefaultConfig() => config = new Configuration(); protected override void LoadConfig() { base.LoadConfig(); try { config = Config.ReadObject(); if (config == null) throw new Exception("config null"); } catch (Exception ex) { PrintWarning($"配置读取失败,已重建默认配置:{ex.Message}"); config = new Configuration(); } if (config.TimeoutSeconds < 1) config.TimeoutSeconds = 1; SaveConfig(); } protected override void SaveConfig() => Config.WriteObject(config); // ======================= 权限 ======================= private const string PermUse = "smartsign.use"; // 零配置:默认全员可用(封禁由平台 /user 拦);服主可开 RequirePermission 限授权玩家。 private bool HasUse(BasePlayer player) { if (player == null) return false; if (!config.RequirePermission) return true; return permission.UserHasPermission(player.UserIDString, PermUse) || player.IsAdmin; } // ======================= 色板 / 字体(v0.3.0 DW-style 深渊军蓝,映射见 SPEC §2.2;RGBA 0-1)======================= private const string ColDim = "0.02 0.03 0.06 0.65"; // 全屏 veil(深军蓝暗化) private const string ColPanel = "0.094 0.129 0.235 1"; // 主窗底 #18213C private const string ColPanel2 = "0.098 0.118 0.212 1"; // 二级面板 #191E36 private const string ColCard = "0.102 0.102 0.176 1"; // 最深衬/图标底/缩略底 #1A1A2D private const string ColCard2 = "0.094 0.125 0.227 1"; // 卡片默认 #18203A private const string ColInk = "1 1 1 1"; // 主文本 白 private const string ColSub = "0.612 0.639 0.686 1"; // 次要文本 #9CA3AF private const string ColDimTxt = "0.42 0.46 0.55 1"; // dim 文本(军蓝灰) private const string ColAccent = "0.376 0.647 0.98 1"; // 浅蓝强调 #60A5FA(高亮/徽标/选中态文字/主按钮文字) private const string ColAccentSoft = "0.059 0.204 0.376 1"; // 主按钮底 #0F3460(原"软强调"背景→DW 实心主按钮) private const string ColSel = "0.35 0.55 0.85 0.6"; // 选中态叠色(半透浅蓝) private const string ColLine = "0.098 0.192 0.349 0.8"; // 分割线 #19315A 0.8α private const string ColClear = "0 0 0 0"; private const string ColChip = "0.102 0.102 0.176 0.88"; // 叠图角标底(深军蓝近不透,保白字可读) private const string ColGold = "0.918 0.702 0.031 1"; // 金 #EAB308(高亮/货币) private const string ColPurple = "0.659 0.333 0.969 1"; // 紫 #A855F7(方案/编排徽标) private const string ColOk = "0.188 0.388 0.224 1"; // 绿 #306339(信誉≥80 / 已验证) private const string ColOkSoft = "0.188 0.388 0.224 0.35"; private const string ColBad = "0.937 0.267 0.267 1"; // 红 #EF4444(信誉<40 / 封禁) private const string ColBadSoft = "0.937 0.267 0.267 0.3"; // ============ v0.5.0 全屏 web-mirror 令牌(配色抄 ss.rustxz.com · 字号抄 ServerPanel)============ private const string FsBg = "0.078 0.086 0.11 0.985"; // 全屏深底 private const string FsNav = "0.105 0.115 0.145 1"; // 顶栏 private const string FsCard = "0.135 0.145 0.175 1"; // 卡片底 private const string FsCard2 = "0.165 0.175 0.205 1"; // 卡片图底/深衬 private const string FsThumb = "0.09 0.10 0.125 1"; // 缩略图深底(letterbox 留白) private const string FsAcc = "0.86 0.45 0.19 1"; // 锈橙强调(logo/激活tab/主按钮/滚动手柄) private const string FsAccSoft = "0.86 0.45 0.19 0.16"; // 橙淡底(次按钮) private const string FsLine = "0.32 0.34 0.4 0.45"; // 分割线 private const string FsSbHandle = "0.86 0.45 0.19 0.72"; // BetterTC 圆角橙手柄 private const string FsInk = "0.96 0.96 0.975 1"; // 主文本 private const string FsSub = "0.62 0.645 0.69 1"; // 次要文本 private const string FsDim = "0.44 0.46 0.52 1"; // 暗文本 private const string FsOk = "0.35 0.62 0.42 1"; // 正常/已验证 绿 private const int FzTitle = 20, FzLabel = 14, FzBody = 12, FzSmall = 10, FzTiny = 9, FzNav = 15, FzTab = 13; // ServerPanel 字号档 private const string FontBold = "robotocondensed-bold.ttf"; private const string FontReg = "robotocondensed-regular.ttf"; // ======================= 层级名 ======================= private const string UiRoot = "smartsign.root"; private const string UiEnt = "smartsign.ent"; // 智能实体接管 CUI(按 E) private const string UiPicker = "smartsign.picker"; // 加图选择器叠层(相册详情→+加图) private const string UiImg = "smartsign.img"; // 广场单图详情叠层(脱离 painted 实体,纯 cropId 驱动) private const string UiCompose = "smartsign.compose"; // 游戏内新建方案(F11) private const string UiComposePick = "smartsign.compose.picker"; // 新建方案·选帧/源图叠层 private const string UiInput = "smartsign.input"; // F5 通用文本输入弹层(相册名/邮箱/验证码/评论/举报理由) private const string UiZoom = "smartsign.zoom"; // F4 大图预览层(等比大图 + 场景化贴牌钮) private const string UiSignFilter = "smartsign.signfilter"; // 按牌型筛选选择清单叠层(素材库/相册/广场相册) // 每玩家各标签页最近拉取的数据(切页/刷新时复用,命令回查免再请求)。 private readonly Dictionary> lastAlbums = new Dictionary>(); private readonly Dictionary lastAlbumDetail = new Dictionary(); private readonly Dictionary> lastPlaza = new Dictionary>(); private readonly Dictionary> lastPlazaImgs = new Dictionary>(); // 广场相册内图(大图层 zoom plaza 回查 sticker) private readonly Dictionary lastNotif = new Dictionary(); private readonly Dictionary lastEmail = new Dictionary(); private readonly Dictionary lastUser = new Dictionary(); // 待输入的文本动作(chat 捕获复用评论那套):value = 动作描述,如 "album.create" / "album.rename:" / "email" private readonly Dictionary pendingText = new Dictionary(); // 广场单图详情当前目标 cropId(赞/评/举报按此,脱离 painted 实体) private readonly Dictionary imgTarget = new Dictionary(); // 每玩家最近一次 /img 展示的成品(选图贴牌时按 cropId 回查 w/h/url,免再请求)。 private readonly Dictionary> lastShown = new Dictionary>(); // —— v0.5.0 全屏 CUI 每玩家界面态 —— private class FsLibState { public int Seg; public int Sort; public string Query; } // 素材库:0成品/1原图 · 0最新/1最热 · 搜索词 private readonly Dictionary fsLib = new Dictionary(); private readonly Dictionary fsSetsKind = new Dictionary(); // 工作台类型页 0动图/1矩阵/2矩阵动图 private readonly Dictionary imgBack = new Dictionary(); // 作品详情返回上下文:mine|album:|plaza:|notif // —— 按牌型筛选(素材库/相册/广场相册共用;选中 prefab→按其比例组过滤,导航切换即重置)—— private readonly Dictionary signFilter = new Dictionary(); // 当前牌型筛选:选中 prefab(无=全部) private readonly Dictionary gridCtx = new Dictionary(); // 当前网格视图:mine|album:|plaza:(筛选重绘定位 + 切换重置) private readonly Dictionary lastPlazaDetail = new Dictionary(); // 广场相册详情缓存(筛选重绘用) private readonly Dictionary lastImg = new Dictionary(); // 最近一次详情响应(大图层回查) // 每玩家「我的方案」缓存(动图/矩阵/矩阵动图,选方案贴牌时回查)。 private readonly Dictionary lastSets = new Dictionary(); // 游戏内编排(F11):每玩家编排中的方案草稿 + 面前实时预览实体。 private readonly Dictionary composing = new Dictionary(); private readonly Dictionary previewEnt = new Dictionary(); private class ComposeState { public string Kind = "anim"; // anim|matrix|animmatrix public List Frames = new List(); // anim/animmatrix=帧序列(≤5);matrix=[0]源图 public List FrameB = new List(); // 每帧独立亮度(与 Frames 平行) public int Cols = 3, Rows = 2; public float Speed = 1f; public int Brightness = 6; // 整体亮度(矩阵源用;动图类=默认值/一键套用) public string TilePrefab = "sign.neon.xl"; public int AlbumIdx = -1; // -1=不挂;否则 lastAlbums 下标 public string EditId; // 非空=编辑已存方案(锁类型;保存修改=update / 另存为=create) public string EditName; // 编辑目标原名(保存修改沿用;另存为加「副本」) } // 本服已画牌本地映射:entityId(net.ID) → 图信息。持久化到 oxide/data/.json。 // 用途:按 E 拦截时判「智能实体」+ 轮询下架时按 cropId 找实体。entity net.ID 存档稳定跨重启。 private class PaintedInfo { public string CropId, Owner, Prefab; public int W, H; public List AllCrops; // 实体用到的全部 crop(动图多帧/矩阵源);旧档 null=仅 CropId public string Wall; // 矩阵墙分组 id(=锚点 entityId);null=单牌 public float Spd; // 墙动画速度(重启后重建同步驱动);0=静态 public int B; // v0.5.0 E接管亮度(1-10;0=未调过=默认 6)。调亮度=按 ?b= 重下帧重写(烘焙式) } private Dictionary painted = new Dictionary(); // 纹理 CRC → 作品信息(拾取重放 robust 认回:纹理随物品走且 CRC 不变,重放时按 CRC 认回智能状态,不限时间/位置) private Dictionary crcToInfo = new Dictionary(); private readonly Dictionary lastInteract = new Dictionary(); // 按 E 冷却 private readonly Dictionary entityCuiTarget = new Dictionary(); // 玩家当前实体 CUI 目标 entityId private Timer pollTimer; // 远程下架轮询 // ======================= 生命周期 ======================= private void Init() { permission.RegisterPermission(PermUse, this); AddCovalenceCommand("img", nameof(CmdImg)); } private void OnServerInitialized() { try { painted = Interface.Oxide.DataFileSystem.ReadObject>(Name) ?? new Dictionary(); } catch { painted = new Dictionary(); } try { crcToInfo = Interface.Oxide.DataFileSystem.ReadObject>(Name + "_crc") ?? new Dictionary(); } catch { crcToInfo = new Dictionary(); } // D7 圆角引擎自检:验证 Texture2D→EncodeToPNG→FileStorage 链在 headless 服务端可用(失败仅记日志,CUI 退化为无圆角不崩) try { var t1 = RoundTex(200, 120, 10); Puts($"[SmartSign] v{Version} 圆角引擎自检 OK:样例贴图 crc {t1}(已缓存 {_texCache.Count} 张)"); } catch (Exception ex) { PrintWarning($"[SmartSign] 圆角引擎自检失败(CUI 圆角可能不显):{ex.Message}"); } // 启动远程轮询:下架单 + 游戏内播报(间隔 <10s 视为关闭,防误配打爆平台) if (config.PollSeconds >= 10f) pollTimer = timer.Every(config.PollSeconds, () => { ServerMgr.Instance.StartCoroutine(PollTakedowns()); ServerMgr.Instance.StartCoroutine(PollNotices()); }); // 重启/重载恢复:已贴霓虹重新免电、矩阵墙重建同步动画驱动(等实体就绪再跑) timer.Once(5f, RestorePaintedNeons); timer.Once(6f, StartupNormalizePainted); // 解锁历史/实验遗留的锁定 + 为拾取重放建 CRC 映射 } /// 启动:解锁历史/锁定实验遗留(Locked/Reserved3/OwnerID 复原)+ 为拾取重放建 CRC 映射。 private void StartupNormalizePainted() { int n = 0; bool crcAdded = false; foreach (var kv in painted) { if (!(FindSign(kv.Key) is Signage sign) || sign.IsDestroyed) continue; if (sign.HasFlag(BaseEntity.Flags.Locked) || sign.HasFlag(BaseEntity.Flags.Reserved3)) { SuppressNativeEdit(sign, false); n++; } if (string.IsNullOrEmpty(kv.Value.Wall)) crcAdded |= RecordCrc(sign, kv.Value); } if (n > 0) Puts($"[SmartSign] 解锁 {n} 块智能牌(锁定实验回退)"); if (crcAdded) SaveCrcMap(); } /// 把 sign 当前纹理 CRC → info 记入 crcToInfo(拾取重放按 CRC 认回)。返回是否新增。 private bool RecordCrc(Signage sign, PaintedInfo info) { var crcs = sign?.GetTextureCRCs(); if (crcs == null) return false; bool added = false; foreach (var c in crcs) if (c != 0 && !crcToInfo.ContainsKey(c)) { crcToInfo[c] = info; added = true; } return added; } private void SavePainted() { try { Interface.Oxide.DataFileSystem.WriteObject(Name, painted); } catch (Exception ex) { if (config.Debug) PrintWarning($"落盘 painted 失败: {ex.Message}"); } } private void SaveCrcMap() { try { Interface.Oxide.DataFileSystem.WriteObject(Name + "_crc", crcToInfo); } catch (Exception ex) { if (config.Debug) PrintWarning($"落盘 crc 映射失败: {ex.Message}"); } } // F16:拾取/摧毁清理 painted(脏标记 + 5s 合并落盘,防基地衰减/爆破时高频掉牌打爆磁盘)。 private Timer paintedSaveTimer; private void MarkPaintedDirty() { if (paintedSaveTimer != null) return; // 已排程本轮合并 paintedSaveTimer = timer.Once(5f, () => { paintedSaveTimer = null; SavePainted(); }); } /// 实体被摧毁(拾取/爆破/衰减)→ 移除其 painted 条目;属墙组且组内全灭 → StopWallAnim。 /// 拾取重放认回改由 OnEntityBuilt 按纹理 CRC 完成(纹理随物品走),此处无需记位置。 private void OnEntityKill(BaseNetworkable entity) { if (painted.Count == 0 || entity == null || entity.net == null) return; // 零 painted 时热路径不分配(每实体死都触发) var eid = entity.net.ID.Value.ToString(); if (!painted.TryGetValue(eid, out var info)) return; // 非智能实体 → 忽略(此路径每次实体死都走,故先廉价查表早返) string wall = info.Wall; painted.Remove(eid); if (!string.IsNullOrEmpty(wall)) { bool anyLeft = false; foreach (var kv in painted) if (kv.Value.Wall == wall) { anyLeft = true; break; } if (!anyLeft) StopWallAnim(wall); // 墙组全灭 → 停总动画驱动 } MarkPaintedDirty(); } /// 玩家部署画布:延迟等 OnDeployed 从物品恢复纹理后,先按 CRC 认回(纹理随物品走·CRC 不变·robust·不限时间位置), /// 认不回再走「附近刚拾取同型」位置启发式重下贴回。 private void OnEntityBuilt(Planner planner, GameObject go) { if (go == null) return; var sign = go.ToBaseEntity() as Signage; if (sign == null || sign.net == null) return; var builder = planner?.GetOwnerPlayer(); timer.Once(1.2f, () => TryRelinkOnBuilt(sign, builder)); } private void TryRelinkOnBuilt(Signage sign, BasePlayer builder) { if (sign == null || sign.IsDestroyed || sign.net == null) return; var eid = sign.net.ID.Value.ToString(); if (painted.ContainsKey(eid)) return; // ① CRC 认回:纹理随物品走且 CRC 不变 → 直接认回智能状态,无需重下 var crcs = sign.GetTextureCRCs(); if (crcs != null) foreach (var c in crcs) if (c != 0 && crcToInfo.TryGetValue(c, out var ci)) { painted[eid] = new PaintedInfo { CropId = ci.CropId, Owner = ci.Owner, Prefab = sign.ShortPrefabName, W = ci.W, H = ci.H, AllCrops = new List { ci.CropId } }; SavePainted(); if (builder != null && builder.IsConnected) NotifyOk(builder, "已认回这块牌的智能贴图"); return; } // 只按 CRC 认回(纹理随物品走);认不回=纹理不在(空白牌/换了别的牌) → 不误判,玩家 /img 重贴即可。 // 已去掉按位置的启发式兜底:曾致「拾取后放个空画框也被误判智能 / 放错位置认回却空白」。 } // 反涂鸦保护:原生画布保存(UpdateSign 存完触发 OnSignUpdated)后,若是智能牌 → 自动把原图贴回,涂鸦作废、保智能状态。 // (原生画布弹出=纯客户端拦不住;此为不依赖 CanUpdateSign 的兜底,插件重贴走 FileStorage 不再触发本钩子,无循环。) private void OnSignUpdated(Signage sign, BasePlayer player) { if (sign == null || sign.net == null) return; if (!painted.TryGetValue(sign.net.ID.Value.ToString(), out var info)) return; ServerMgr.Instance.StartCoroutine(RepaintRelink(sign, info, player, "原生涂鸦不会生效——智能牌已还原,请用 /img 管理")); } /// 重下 CropId 的图写回画布并登记 painted(拾取重放兜底重贴 / 原生涂鸦后还原)。notif=完成提示语(空=不提示)。 private IEnumerator RepaintRelink(Signage sign, PaintedInfo info, BasePlayer builder, string notif) { if (sign == null || sign.IsDestroyed || info == null || string.IsNullOrEmpty(info.CropId)) yield break; var prefab = sign.ShortPrefabName; if (!SignSizes.TryGetValue(prefab, out var size)) yield break; byte[] png = null; yield return DownloadBytes($"{config.ApiBase}/u/{info.CropId}.png?w={size.W}&h={size.H}", b => png = b); if (png == null || png.Length == 0 || sign == null || sign.IsDestroyed) yield break; try { sign.EnsureInitialized(); const uint frame = 0; var crcs = sign.GetTextureCRCs(); if (crcs != null && frame < crcs.Length && crcs[frame] != 0) FileStorage.server.RemoveExact(crcs[frame], FileStorage.Type.png, sign.net.ID, frame); uint id = FileStorage.server.Store(png, FileStorage.Type.png, sign.net.ID, frame); if (sign.textureIDs != null && frame < sign.textureIDs.Length) sign.textureIDs[frame] = id; sign.SendNetworkUpdate(); } catch (Exception ex) { if (config.Debug) PrintWarning($"重链贴图失败: {ex.Message}"); yield break; } var ni = new PaintedInfo { CropId = info.CropId, Owner = info.Owner, Prefab = prefab, W = size.W, H = size.H, AllCrops = new List { info.CropId } }; painted[sign.net.ID.Value.ToString()] = ni; SavePainted(); if (RecordCrc(sign, ni)) SaveCrcMap(); if (builder != null && builder.IsConnected && !string.IsNullOrEmpty(notif)) NotifyOk(builder, notif); } private void Unload() { pollTimer?.Destroy(); if (paintedSaveTimer != null) { paintedSaveTimer.Destroy(); paintedSaveTimer = null; SavePainted(); } // 冲刷未落盘的 painted 变更 lastShown.Clear(); lastSets.Clear(); composing.Clear(); foreach (var e in previewEnt.Values) if (e != null && !e.IsDestroyed) e.Kill(); previewEnt.Clear(); foreach (var t in wallTimers.Values) t?.Destroy(); wallTimers.Clear(); entityCuiTarget.Clear(); pendingText.Clear(); imgTarget.Clear(); lastAlbumDetail.Clear(); lastPlazaImgs.Clear(); if (BasePlayer.activePlayerList == null) return; foreach (var p in BasePlayer.activePlayerList) if (p != null) { CuiHelper.DestroyUi(p, UiRoot); CuiHelper.DestroyUi(p, UiEnt); CuiHelper.DestroyUi(p, UiPicker); CuiHelper.DestroyUi(p, UiImg); CuiHelper.DestroyUi(p, UiCompose); CuiHelper.DestroyUi(p, UiComposePick); CuiHelper.DestroyUi(p, UiInput); CuiHelper.DestroyUi(p, UiZoom); } } private void OnPlayerDisconnected(BasePlayer player, string reason) { if (player == null) return; KillPreview(player); composing.Remove(player.userID); lastShown.Remove(player.userID); lastSets.Remove(player.userID); entityCuiTarget.Remove(player.userID); pendingText.Remove(player.userID); imgTarget.Remove(player.userID); lastInteract.Remove(player.userID); // T1-T5 页数据缓存随断线清理,防长期运行累积 lastAlbums.Remove(player.userID); lastAlbumDetail.Remove(player.userID); lastPlaza.Remove(player.userID); lastPlazaImgs.Remove(player.userID); lastNotif.Remove(player.userID); lastEmail.Remove(player.userID); lastUser.Remove(player.userID); } // ======================= /img 命令 ======================= private void CmdImg(IPlayer iplayer, string command, string[] args) { var player = iplayer.Object as BasePlayer; if (player == null) { iplayer.Reply("请在游戏内使用 /img"); return; } if (!HasUse(player)) { NotifyErr(player, "你没有使用智能贴图的权限"); return; } // 瞄准着已贴图的智能牌 → 直接进「编辑智能牌」管理 CUI(清除/换图/举报/社交),避开原生画布冲突 if (GetLookedSign(player, out var sign) && sign?.net != null && painted.TryGetValue(sign.net.ID.Value.ToString(), out var info)) { bool authed = player.IsAdmin || info.Owner == player.userID.ToString() || PlayerCanEdit(sign, player); ServerMgr.Instance.StartCoroutine(OpenEntityCui(player, sign.net.ID.Value.ToString(), info, authed)); return; } ServerMgr.Instance.StartCoroutine(OpenMain(player, "mine")); } /// 拉 /user + 当前页数据后弹主 CUI;封禁则拒。 private IEnumerator OpenMain(BasePlayer player, string tab) { UserResp user = null; yield return GetJson($"/api/game/user/{player.userID}", r => user = r); if (user == null || !user.ok) { NotifyErr(player, "连接智能贴图平台失败,请稍后再试"); yield break; } if (user.banned) { NotifyErr(player, "你的账号已被封禁" + (string.IsNullOrEmpty(user.bannedReason) ? "" : ":" + user.bannedReason)); yield break; } lastUser[player.userID] = user; // 通知未读数每次开界面都刷新(导航徽标全页可见,不止 notif 页) yield return GetJson($"/api/game/notifications?steamid={player.userID}", r => { if (r != null && r.ok) lastNotif[player.userID] = r; }); // 按当前页拉数据入缓存(切页/命令回查复用;非 mine 不覆盖 lastShown 贴牌成品列表) yield return LoadTabData(player, tab); if (player == null || !player.IsConnected) yield break; ShowMainCui(player, user, tab); } /// 按 tab 拉该页数据入对应缓存。契约见 §3(各端点均返回 ok=true)。 private IEnumerator LoadTabData(BasePlayer player, string tab) { var uid = player.userID; switch (tab) { case "mine": var stickers = new List(); yield return GetJson($"/api/game/stickers?steamid={uid}", r => { if (r?.stickers != null) stickers = r.stickers; }); lastShown[uid] = stickers; break; case "sets": yield return GetJson($"/api/game/sets?steamid={uid}", r => { if (r != null && r.ok) lastSets[uid] = r; }); break; case "albums": yield return GetJson($"/api/game/albums?steamid={uid}", r => { if (r != null && r.ok) lastAlbums[uid] = r.albums ?? new List(); }); break; case "plaza": yield return GetJson("/api/game/plaza", r => { if (r != null && r.ok) lastPlaza[uid] = r.albums ?? new List(); }); break; // notif 数据由 OpenMain 每次开界面统一拉(导航徽标全页可见),此处不再重复 case "me": yield return GetJson($"/api/game/me/email?steamid={uid}", r => { if (r != null && r.ok) lastEmail[uid] = r; }); break; } } // ======================= HTTP(内置密钥鉴权) ======================= private IEnumerator GetJson(string path, Action cb) where T : class { UnityWebRequest www = null; try { www = UnityWebRequest.Get(config.ApiBase + path); www.timeout = config.TimeoutSeconds; www.SetRequestHeader("x-smartsign-secret", config.PluginSecret); } catch (Exception ex) { if (config.Debug) PrintWarning($"GET 构造失败 {path}: {ex.Message}"); www?.Dispose(); cb(null); yield break; } yield return www.SendWebRequest(); if (www.result != UnityWebRequest.Result.Success) { if (config.Debug) PrintWarning($"GET {path} 失败: {www.error}"); www.Dispose(); cb(null); yield break; } var body = www.downloadHandler.text; www.Dispose(); T obj = null; try { obj = JsonConvert.DeserializeObject(body); } catch (Exception ex) { if (config.Debug) PrintWarning($"GET {path} 解析失败: {ex.Message}"); } cb(obj); } private IEnumerator PostJson(string path, string json, Action cb) where T : class { var bytes = Encoding.UTF8.GetBytes(json ?? "{}"); using (var w = new UnityWebRequest(config.ApiBase + path, "POST", new DownloadHandlerBuffer(), new UploadHandlerRaw(bytes))) { w.timeout = config.TimeoutSeconds; w.SetRequestHeader("x-smartsign-secret", config.PluginSecret); w.SetRequestHeader("content-type", "application/json"); yield return w.SendWebRequest(); if (w.result != UnityWebRequest.Result.Success) { if (config.Debug) PrintWarning($"POST {path} 失败: {w.error}"); cb(null); yield break; } T obj = null; try { obj = JsonConvert.DeserializeObject(w.downloadHandler.text); } catch (Exception ex) { if (config.Debug) PrintWarning($"POST {path} 解析失败: {ex.Message}"); } cb(obj); } } private IEnumerator DeleteJson(string path, Action cb) where T : class { using (var w = new UnityWebRequest(config.ApiBase + path, "DELETE", new DownloadHandlerBuffer(), null)) { w.timeout = config.TimeoutSeconds; w.SetRequestHeader("x-smartsign-secret", config.PluginSecret); yield return w.SendWebRequest(); if (w.result != UnityWebRequest.Result.Success) { if (config.Debug) PrintWarning($"DELETE {path} 失败: {w.error}"); cb(null); yield break; } T obj = null; try { obj = JsonConvert.DeserializeObject(w.downloadHandler.text); } catch (Exception ex) { if (config.Debug) PrintWarning($"DELETE {path} 解析失败: {ex.Message}"); } cb(obj); } } /// server_key:配置优先,否则公网 IP:端口。 private string ServerKey() { if (!string.IsNullOrEmpty(config.ServerKey)) return config.ServerKey; return $"{ConVar.Server.ip}:{ConVar.Server.port}"; } // ======================= 选图贴牌(S3.2;Signage 类:木牌/画框/横幅/画布等。neon/相框/南瓜留 S4) ======================= /// 玩家正视的可贴 Signage(≤MaxDistance 米)。CUI 光标不动镜头,选图时重投射命中同一牌。 private bool GetLookedSign(BasePlayer player, out Signage sign) { sign = null; if (player == null || player.eyes == null) return false; if (!Physics.Raycast(player.eyes.HeadRay(), out RaycastHit hit, config.MaxDistance, Physics.DefaultRaycastLayers, QueryTriggerInteraction.Ignore)) return false; sign = hit.GetEntity() as Signage; return sign != null; } /// 贴牌核心(F2 泛化:直传 Sticker,不再回查 lastShown;相册详情/广场收藏后均复用)。 /// 校验(目标/尺寸/权限) → 报备(平台验封禁/归属/下架) → 下载 → 贴上。 private IEnumerator PaintFlow(BasePlayer player, Sticker s, bool collected = false) { if (s == null || string.IsNullOrEmpty(s.id)) { Reply(player, "没找到这张成品,请重新打开 /img"); yield break; } if (!GetLookedSign(player, out var sign)) { Reply(player, $"请对准要贴的木牌/画框(≤{config.MaxDistance:0}米)再选图"); yield break; } var prefab = sign.ShortPrefabName; if (!SignSizes.TryGetValue(prefab, out var size)) { NotifyErr(player, "面前的实体暂不支持贴图(灯牌/相框将在后续支持)"); yield break; } // 一刀一张模型:一张图存本组最大尺寸,按「比例组」匹配(组内比例一致),贴时缩到目标牌纹理尺寸 if (s.group != size.Group) { NotifyErr(player, $"比例不符:这张图是 {s.groupRatio} 比例,跟面前的牌子({size.W}×{size.H})不是同一组,请对准同比例的牌子"); yield break; } if (!player.IsAdmin && !PlayerCanEdit(sign, player)) { NotifyErr(player, "你在此处没有编辑权限(需在自己领地/工具柜授权范围内)"); yield break; } var entityId = sign.net.ID.Value.ToString(); var payload = JsonConvert.SerializeObject(new { steamid = player.userID.ToString(), serverKey = ServerKey(), serverIp = ConVar.Server.ip, entityId, signType = prefab, cropId = s.id, }); PaintResp rep = null; yield return PostJson("/api/game/paint", payload, r => rep = r); if (rep == null) { NotifyErr(player, "报备失败,请稍后再试"); yield break; } if (!rep.ok) { NotifyErr(player, rep.error ?? "无法贴图(可能已封禁或图已下架)"); yield break; } byte[] png = null; // 按目标牌型纹理尺寸请求缩放版本(web 端 sharp 缩放,同组等比不变形) var dlUrl = s.url + (s.url.Contains("?") ? "&" : "?") + "w=" + size.W + "&h=" + size.H; yield return DownloadBytes(dlUrl, b => png = b); if (png == null || png.Length == 0) { NotifyErr(player, "下载图片失败,请重试"); yield break; } if (sign == null || sign.IsDestroyed) { NotifyErr(player, "牌子已不在了"); yield break; } try { sign.EnsureInitialized(); if (sign is NeonSign neon) { // F8:静态图贴霓虹 → 填满所有帧槽(多帧动画霓虹只写 0 帧会闪黑)+ 免电常亮 WriteNeonAllFrames(neon, png); PowerNeon(neon); } else { const uint frame = 0; var crcs = sign.GetTextureCRCs(); if (crcs != null && frame < crcs.Length && crcs[frame] != 0) FileStorage.server.RemoveExact(crcs[frame], FileStorage.Type.png, sign.net.ID, frame); uint id = FileStorage.server.Store(png, FileStorage.Type.png, sign.net.ID, frame); if (sign.textureIDs != null && frame < sign.textureIDs.Length) sign.textureIDs[frame] = id; sign.SendNetworkUpdate(); } } catch (Exception ex) { if (config.Debug) PrintWarning($"贴图失败: {ex.Message}"); NotifyErr(player, "贴图失败,请重试"); yield break; } // 记本地映射(按 E 拦截 + 远程下架用) painted[entityId] = new PaintedInfo { CropId = s.id, Owner = player.userID.ToString(), Prefab = prefab, W = size.W, H = size.H, AllCrops = new List { s.id } }; SavePainted(); if (RecordCrc(sign, painted[entityId])) SaveCrcMap(); // 记纹理 CRC → 作品(拾取重放按 CRC robust 认回) SuppressNativeEdit(sign, true); // F7:贴图成功→锁牌抑制原生编辑器(config lock 方案) NotifyOk(player, collected ? $"已收藏到你的成品并贴上「{s.name}」!" : $"已贴上「{s.name}」!"); CuiHelper.DestroyUi(player, UiRoot); } private IEnumerator DownloadBytes(string url, Action cb) { UnityWebRequest www = null; try { www = UnityWebRequest.Get(url); www.timeout = config.TimeoutSeconds; } catch { cb(null); yield break; } yield return www.SendWebRequest(); if (www.result != UnityWebRequest.Result.Success) { if (config.Debug) PrintWarning($"下载失败 {url}: {www.error}"); www.Dispose(); cb(null); yield break; } var data = www.downloadHandler.data; www.Dispose(); cb(data); } // ======================= 霓虹引擎(P2:免电 F8 / 多帧动图 / 矩阵墙 D1;机制移植自 RustxzTieTu) ======================= private const int NeonOnPower = 25; // UpdateHasPower 喂的固定功率(只管点亮;亮度已按 ?b= 烘焙进像素) // 霓虹物理尺寸(米,W×H):xl=2.5m²、125x125=1.25m²、125x215=2.15×1.25m(横牌·宽>高,游戏内只能横摆),矩阵墙间距按此(F13→修正朝向)。 private static readonly Dictionary NeonPhys = new Dictionary { { "sign.neon.xl", new Vector2(2.5f, 2.5f) }, { "sign.neon.xl.animated", new Vector2(2.5f, 2.5f) }, { "sign.neon.125x125", new Vector2(1.25f, 1.25f) }, { "sign.neon.125x215", new Vector2(2.15f, 1.25f) }, { "sign.neon.125x215.animated", new Vector2(2.15f, 1.25f) }, }; /// 免电常亮 + 动画循环(speed>0 先设 animationSpeed,循环按新速度重启)。抄 RustxzTieTu.PowerNeon。 /// startAnim=false 只供电不启循环(矩阵动图逐块贴完后走 StartNeonAnimsSynced 同 tick 统一启动,防各动各的)。 private void PowerNeon(NeonSign neon, float speed = 0f, bool startAnim = true) { if (neon == null || neon.IsDestroyed) return; if (speed > 0f) neon.animationSpeed = speed; neon.UpdateHasPower(NeonOnPower, 0); if (startAnim && neon.textureIDs != null && neon.textureIDs.Length > 1) { if (neon.IsInvoking(neon.animationLoopAction)) neon.CancelInvoke(neon.animationLoopAction); float sp = neon.animationSpeed > 0 ? neon.animationSpeed : 3f; neon.InvokeRepeating(neon.animationLoopAction, sp, sp); neon.isAnimating = true; } neon.SendNetworkUpdate(); } // 矩阵墙同步动画驱动:每面墙一个总定时器,同一 tick 推进全部块(根治各块独立计时的相位漂移=「时不时一两块错帧/色差」)。 private readonly Dictionary wallTimers = new Dictionary(); private void StopWallAnim(string wallId) { if (string.IsNullOrEmpty(wallId)) return; if (wallTimers.TryGetValue(wallId, out var t)) { t?.Destroy(); wallTimers.Remove(wallId); } } /// 整面墙收编到一个定时器:取消各块自身循环、帧归零,此后由总定时器同 tick 逐块推帧。 private void StartWallAnim(string wallId, List cells, float speed) { StopWallAnim(wallId); var alive = new List(); foreach (var n in cells) { if (n == null || n.IsDestroyed || n.textureIDs == null || n.textureIDs.Length <= 1) continue; if (n.IsInvoking(n.animationLoopAction)) n.CancelInvoke(n.animationLoopAction); n.animationSpeed = speed > 0 ? speed : (n.animationSpeed > 0 ? n.animationSpeed : 3f); n.currentFrame = 0; n.isAnimating = true; n.SendNetworkUpdate(); alive.Add(n); } if (alive.Count == 0) return; float sp = speed > 0 ? speed : 3f; wallTimers[wallId] = timer.Every(sp, () => { alive.RemoveAll(x => x == null || x.IsDestroyed); if (alive.Count == 0) { StopWallAnim(wallId); return; } foreach (var n in alive) n.animationLoopAction?.Invoke(); // 同 tick 全墙推进 → 永久同帧 }); } /// 重启/重载后按 painted 存档恢复:单牌免电+自循环;墙分组免电+重建同步驱动。 private void RestorePaintedNeons() { var walls = new Dictionary>(); var wallSpd = new Dictionary(); int restored = 0; foreach (var kv in painted) { var neon = FindSign(kv.Key) as NeonSign; if (neon == null || neon.IsDestroyed) continue; restored++; if (!string.IsNullOrEmpty(kv.Value.Wall)) { if (!walls.TryGetValue(kv.Value.Wall, out var l)) { l = new List(); walls[kv.Value.Wall] = l; wallSpd[kv.Value.Wall] = kv.Value.Spd; } l.Add(neon); PowerNeon(neon, 0f, startAnim: false); } else { PowerNeon(neon); // 单牌:免电,多帧则自循环 } } foreach (var kv in walls) StartWallAnim(kv.Key, kv.Value, wallSpd[kv.Key]); if (config.Debug && restored > 0) PrintWarning($"[SmartSign] 恢复 {restored} 块已贴霓虹({walls.Count} 面墙)"); } /// 把每帧 PNG 写进对应帧槽(先清旧 CRC)。pngByFrame 长度=槽数,null 帧跳过(调用方负责取模回填)。 private static void WriteNeonFrames(NeonSign neon, byte[][] pngByFrame) { neon.EnsureInitialized(); var ids = neon.textureIDs; if (ids == null || ids.Length == 0) { neon.textureIDs = new uint[1]; ids = neon.textureIDs; } for (uint f = 0; f < ids.Length && f < pngByFrame.Length; f++) { var png = pngByFrame[f]; if (png == null) continue; if (ids[f] != 0) FileStorage.server.RemoveExact(ids[f], FileStorage.Type.png, neon.net.ID, f); ids[f] = FileStorage.server.Store(png, FileStorage.Type.png, neon.net.ID, f); } neon.SendNetworkUpdate(); } /// 同一张 PNG 填满全部帧槽(静态图贴多帧霓虹防闪黑)。 private static void WriteNeonAllFrames(NeonSign neon, byte[] png) { neon.EnsureInitialized(); int slots = neon.textureIDs != null && neon.textureIDs.Length > 0 ? neon.textureIDs.Length : 1; var frames = new byte[slots][]; for (int f = 0; f < slots; f++) frames[f] = png; WriteNeonFrames(neon, frames); } private static string WithParams(string url, string extra) => url + (url.Contains("?") ? "&" : "?") + extra; /// 彻底清一块牌:全部帧槽清空(多帧只清 0 帧会留残帧继续播)+ 霓虹停动画断电熄灯。 private static void ClearSignCompletely(Signage sign) { sign.EnsureInitialized(); var crcs = sign.GetTextureCRCs(); if (crcs != null) for (uint f = 0; f < crcs.Length; f++) if (crcs[f] != 0) FileStorage.server.RemoveExact(crcs[f], FileStorage.Type.png, sign.net.ID, f); if (sign.textureIDs != null) for (int f = 0; f < sign.textureIDs.Length; f++) sign.textureIDs[f] = 0; if (sign is NeonSign neon) { if (neon.IsInvoking(neon.animationLoopAction)) neon.CancelInvoke(neon.animationLoopAction); neon.isAnimating = false; neon.UpdateHasPower(0, 0); } sign.SendNetworkUpdate(); } /// ⚠️ 原生画布弹出=纯客户端行为,服务端**四验全无法屏蔽**(假锁/CanUpdateSign/真锁OwnerID=0/真锁休眠ID 全弹,且上锁副作用大:按E不稳、涂鸦后掉智能状态)。 /// 故本方法**只解锁复原、绝不上锁**(去 Locked/Reserved3、OwnerID 还给贴图者)。原生编辑无效改靠 CanUpdateSign 拦保存 + OnSignUpdated 还原。保留签名(PaintFlow/清图/启动仍调用)。 private void SuppressNativeEdit(Signage sign, bool on) { if (sign == null || sign.IsDestroyed) return; bool changed = false; if (sign.HasFlag(BaseEntity.Flags.Reserved3)) { sign.SetFlag(BaseEntity.Flags.Reserved3, false); changed = true; } if (sign.HasFlag(BaseEntity.Flags.Locked)) { sign.SetFlag(BaseEntity.Flags.Locked, false); changed = true; } if (sign.net != null && painted.TryGetValue(sign.net.ID.Value.ToString(), out var pi) && ulong.TryParse(pi.Owner, out var oid) && sign.OwnerID != oid) { sign.OwnerID = oid; changed = true; } if (changed) sign.SendNetworkUpdate(); } // 插件自身权限校验期间置 true → 下方 CanUpdateSign 钩子放行(否则 painted 牌恒 false,会误伤换图/贴牌/owner 判定)。 private bool _internalSignCheck; /// 插件自身查「该玩家在此有无编辑权」:临时绕过 CanUpdateSign 钩子。 /// sign.CanUpdateSign 为同步调用(内部同步 CallHook),guard 期间无协程让出,安全。 private bool PlayerCanEdit(Signage sign, BasePlayer player) { if (sign == null) return false; _internalSignCheck = true; try { return sign.CanUpdateSign(player); } finally { _internalSignCheck = false; } } /// 拦 painted 牌原生编辑「保存」(UpdateSign 上传)的 backstop:原生涂鸦画了会回退。 /// ⚠️ 反编译确认:CanUpdateSign 只 gate UpdateSign 上传,拦不住客户端「弹出」画布(弹出靠真锁 SuppressNativeEdit)。 /// 抑制方案非 off 即生效(默认 lock 也走)。插件自身校验经 _internalSignCheck 放行;插件贴图走 FileStorage 直写不经此 RPC。 private object CanUpdateSign(BasePlayer player, Signage sign) { if (_internalSignCheck) return null; if (config.SignEditSuppress != "off" && sign != null && sign.net != null && painted.ContainsKey(sign.net.ID.Value.ToString())) return false; return null; } // —— 「我的方案」贴牌流 —— /// 动图方案:准星对动画霓虹 → 逐帧下载(目标尺寸+亮度烘焙)塞 textureIDs[f],槽多帧少取模回填防闪,免电播放。 private IEnumerator ApplyAnimSet(BasePlayer player, GameAnimSet set) { if (set.frames == null || set.frames.Count == 0) { NotifyErr(player, "这个方案没有帧"); yield break; } if (!GetLookedSign(player, out var sign)) { Reply(player, $"请对准要贴的动画霓虹灯牌(≤{config.MaxDistance:0}米)再选方案"); yield break; } var neon = sign as NeonSign; if (neon == null) { NotifyErr(player, "面前不是霓虹灯牌——动图方案只能贴到动画霓虹灯牌上"); yield break; } if (neon.textureIDs == null || neon.textureIDs.Length <= 1) { NotifyErr(player, "面前是静态霓虹(只有 1 帧槽),动图请贴到「大型/中型动画霓虹灯牌」"); yield break; } if (!SignSizes.TryGetValue(sign.ShortPrefabName, out var size)) { NotifyErr(player, "面前的实体暂不支持"); yield break; } if (SignSizes.TryGetValue(set.prefab, out var setSize) && setSize.Group != size.Group) { NotifyErr(player, $"比例不符:方案按「{set.label}」比例制作,请对准同比例的动画霓虹"); yield break; } if (!player.IsAdmin && !PlayerCanEdit(sign, player)) { NotifyErr(player, "你在此处没有编辑权限(需在自己领地/工具柜授权范围内)"); yield break; } // 报备(平台验封禁/下架;cropIds=全部帧 → 任一帧被下架时全服可寻) var frameIds = new List(); foreach (var fr in set.frames) if (!string.IsNullOrEmpty(fr.cropId)) frameIds.Add(fr.cropId); PaintResp rep = null; yield return PostJson("/api/game/paint", JsonConvert.SerializeObject(new { steamid = player.userID.ToString(), serverKey = ServerKey(), serverIp = ConVar.Server.ip, entityId = sign.net.ID.Value.ToString(), signType = sign.ShortPrefabName, cropId = set.frames[0].cropId, cropIds = frameIds, }), r => rep = r); if (rep == null || !rep.ok) { NotifyErr(player, rep?.error ?? "报备失败(可能已封禁或图已下架)"); yield break; } int slots = neon.textureIDs.Length; int n = Math.Min(set.frames.Count, slots); // 硬约束①:帧数上限=牌原生槽数 var pngs = new byte[slots][]; for (int f = 0; f < n; f++) { int fb = set.frames[f].brightness > 0 ? set.frames[f].brightness : set.brightness; // 每帧独立亮度 byte[] png = null; yield return DownloadBytes(WithParams(set.frames[f].url, $"w={size.W}&h={size.H}&b={fb}"), b => png = b); if (png == null || png.Length == 0) { NotifyErr(player, $"第 {f + 1} 帧下载失败,请重试"); yield break; } pngs[f] = png; } for (int f = n; f < slots; f++) pngs[f] = pngs[f % n]; // 槽多帧少 → 取模循环回填(防闪黑) if (neon == null || neon.IsDestroyed) { NotifyErr(player, "牌子已不在了"); yield break; } try { WriteNeonFrames(neon, pngs); PowerNeon(neon, set.speed); } catch (Exception ex) { if (config.Debug) PrintWarning($"贴动图失败: {ex.Message}"); NotifyErr(player, "贴动图失败,请重试"); yield break; } var animCrops = new List(); foreach (var fr in set.frames) if (!string.IsNullOrEmpty(fr.cropId)) animCrops.Add(fr.cropId); painted[sign.net.ID.Value.ToString()] = new PaintedInfo { CropId = set.frames[0].cropId, Owner = player.userID.ToString(), Prefab = sign.ShortPrefabName, W = size.W, H = size.H, AllCrops = animCrops, Spd = set.speed, }; SavePainted(); NotifyOk(player, $"已贴上动图「{set.name}」:{n} 帧 × {set.speed:0.#}s,免电循环播放!"); CuiHelper.DestroyUi(player, UiRoot); } /// 矩阵墙(D1 背包消耗+锚点):锚点=方案单牌同款(玩家先放左上角),背包备足 长×宽−1 块; /// 沿 −right/−up 按牌物理尺寸逐块 CreateEntity(每块扣背包 1,生成失败即停、剩余不扣), /// 贴对应瓦片(行主序 tileIdx=rr*cols+rc;真机取证 2026-07-27:−right=观察者右侧、锚点=左上角、列序不镜像), /// 免电点亮;动画矩阵贴完统一启动动画(同帧同速)。每块登记 painted(按 E 接管用)。 /// tiles=普通矩阵 / tilesByFrame=动画矩阵(二选一);reportCrops=[源图/全部帧](报备+E 接管+下架扩散);frameBrightness=每帧独立亮度。 private IEnumerator ApplyMatrixWall(BasePlayer player, string name, string tilePrefab, string label, int cols, int rows, int brightness, float speed, List tiles, List> tilesByFrame, int frameCount, List reportCrops, List frameBrightness) { string reportCropId = reportCrops != null && reportCrops.Count > 0 ? reportCrops[0] : null; if (cols < 1 || rows < 1 || cols * rows > 100) { NotifyErr(player, "方案规格异常"); yield break; } if (!GetLookedSign(player, out var sign)) { Reply(player, $"矩阵墙用法:先把 1 块「{label}」放到墙的左上角当锚点,对准它(≤{config.MaxDistance:0}米)再选方案"); yield break; } var anchor = sign as NeonSign; if (anchor == null || sign.ShortPrefabName != tilePrefab) { NotifyErr(player, $"锚点牌型不符:这个方案的单牌是「{label}」,请先放 1 块同款当锚点并对准它"); yield break; } if (!player.IsAdmin && !PlayerCanEdit(sign, player)) { NotifyErr(player, "你在此处没有编辑权限(需在自己领地/工具柜授权范围内)"); yield break; } int total = cols * rows, needed = total - 1; var itemDef = ItemManager.FindItemDefinition(tilePrefab); if (needed > 0) { if (itemDef == null) { NotifyErr(player, "找不到对应灯牌物品"); yield break; } int have = player.inventory.GetAmount(itemDef.itemid); if (have < needed) { NotifyErr(player, $"背包灯牌不够:墙 {cols}×{rows}={total} 块(锚点算 1),背包还差 {needed - have} 块「{label}」"); yield break; } } // 报备(平台验封禁/下架;矩阵报源图、动画矩阵报首帧)——不过审就不动背包 if (!string.IsNullOrEmpty(reportCropId)) { PaintResp rep = null; yield return PostJson("/api/game/paint", JsonConvert.SerializeObject(new { steamid = player.userID.ToString(), serverKey = ServerKey(), serverIp = ConVar.Server.ip, entityId = anchor.net.ID.Value.ToString(), signType = tilePrefab, cropId = reportCropId, cropIds = reportCrops, }), r => rep = r); if (rep == null || !rep.ok) { NotifyErr(player, rep?.error ?? "报备失败(可能已封禁或图已下架)"); yield break; } } var phys = NeonPhys.TryGetValue(tilePrefab, out var pv) ? pv : new Vector2(2.5f, 2.5f); var basePos = anchor.transform.position; var rot = anchor.transform.rotation; var right = anchor.transform.right; var up = anchor.transform.up; // 逐块生成(锚点=(0,0);−right 横向、−up 纵向;同 RustxzTieTu.WallGenerate) var cells = new List(); int spawned = 0; bool aborted = false; for (int rr = 0; rr < rows && !aborted; rr++) { for (int rc = 0; rc < cols; rc++) { NeonSign cell; if (rr == 0 && rc == 0) cell = anchor; else { var pos = basePos + right * rc * (-phys.x) + up * rr * (-phys.y); var ent = GameManager.server.CreateEntity(anchor.PrefabName, pos, rot) as NeonSign; if (ent == null) { NotifyErr(player, $"第 {rr * cols + rc + 1} 块生成失败,已停止(剩余灯牌未消耗)"); aborted = true; break; } ent.OwnerID = player.userID; ent.Spawn(); player.inventory.Take(null, itemDef.itemid, 1); // D1:每块从背包扣 1 spawned++; cell = ent; } cells.Add(cell); if (cells.Count % 6 == 0) yield return null; // 摊平生成峰值 } } // 逐块贴瓦片(行主序 tileIdx = rr*cols + rc;✅ 真机取证:−right=观察者右侧 → 列序不镜像) int okCount = 0; var lit = new List(); // 贴成功的块(动画矩阵最后统一启动动画) for (int i = 0; i < cells.Count; i++) { var cell = cells[i]; if (cell == null || cell.IsDestroyed) continue; int rr = i / cols, rc = i % cols; int tileIdx = rr * cols + rc; int cslots = cell.textureIDs != null && cell.textureIDs.Length > 0 ? cell.textureIDs.Length : 1; var pngs = new byte[cslots][]; if (tilesByFrame != null) { // 动画矩阵:该格每帧一张(每帧独立亮度),槽多帧少取模回填 int n = Math.Min(Math.Max(frameCount, 1), cslots); bool fail = false; for (int f = 0; f < n; f++) { if (f >= tilesByFrame.Count || tileIdx >= tilesByFrame[f].Count) { fail = true; break; } int fb = frameBrightness != null && f < frameBrightness.Count && frameBrightness[f] > 0 ? frameBrightness[f] : brightness; byte[] png = null; for (int attempt = 0; attempt < 3 && (png == null || png.Length == 0); attempt++) yield return DownloadBytes(WithParams(tilesByFrame[f][tileIdx], $"b={fb}"), b => png = b); if (png == null || png.Length == 0) { fail = true; break; } pngs[f] = png; } if (fail) { if (config.Debug) PrintWarning($"矩阵动图瓦片下载失败 [{i}]"); continue; } for (int f = n; f < cslots; f++) pngs[f] = pngs[f % n]; } else { // 普通矩阵:该格一张,填满全部槽(③ 加 3 次重试:单块下载失败会留一块空白暗牌,兜住瞬时网络抖动) if (tiles == null || tileIdx >= tiles.Count) continue; byte[] png = null; for (int attempt = 0; attempt < 3 && (png == null || png.Length == 0); attempt++) yield return DownloadBytes(WithParams(tiles[tileIdx], $"b={brightness}"), b => png = b); if (png == null || png.Length == 0) { if (config.Debug) PrintWarning($"矩阵瓦片下载失败(重试后) [{i}]"); continue; } for (int f = 0; f < cslots; f++) pngs[f] = png; } try { WriteNeonFrames(cell, pngs); PowerNeon(cell, speed, startAnim: false); // 先只供电;动画贴完统一启动(同帧同速) lit.Add(cell); okCount++; } catch (Exception ex) { if (config.Debug) PrintWarning($"矩阵贴瓦失败[{i}]: {ex.Message}"); } if (rows >= 3 && rc == cols - 1 && rr < rows - 1) Reply(player, $"矩阵墙进度 {rr + 1}/{rows} 行…"); } // 动画矩阵:整面墙收编到一个总定时器,同 tick 推帧(永久同帧同速,不再各自漂移) string wallId = anchor.net.ID.Value.ToString(); StartWallAnim(wallId, lit, speed); // 每块登记 painted(按 E 弹编辑/访客 CUI、远程下架可寻、重启恢复)——详情挂 源图/首帧 crop if (!string.IsNullOrEmpty(reportCropId)) { var tileSize = SignSizes.TryGetValue(tilePrefab, out var tsz) ? tsz : new Sz(256, 256, "sq"); foreach (var cell in cells) { if (cell == null || cell.IsDestroyed) continue; painted[cell.net.ID.Value.ToString()] = new PaintedInfo { CropId = reportCropId, Owner = player.userID.ToString(), Prefab = tilePrefab, W = tileSize.W, H = tileSize.H, AllCrops = reportCrops, Wall = wallId, Spd = speed, }; } SavePainted(); } if (okCount < total) NotifyErr(player, $"矩阵墙「{name}」:{okCount}/{total} 块贴好,{total - okCount} 块下载失败显暗块(网络抖动·已自动重试仍失败)——清掉这面墙重贴一次即可补上。消耗背包 {spawned} 块。"); else NotifyOk(player, $"矩阵墙「{name}」完成:贴好 {total} 块,消耗背包 {spawned} 块灯牌,免电点亮!"); CuiHelper.DestroyUi(player, UiRoot); } // ======================= 游戏内编排(P3 F11:新建方案 CUI + 实时预览 + 写回平台) ======================= // 霓虹 prefab 全路径(预览 spawn 用;shortname 同物品名)。 private static readonly Dictionary NeonPrefabPath = new Dictionary { { "sign.neon.xl", "assets/prefabs/misc/xmas/neon_sign/sign.neon.xl.prefab" }, { "sign.neon.xl.animated", "assets/prefabs/misc/xmas/neon_sign/sign.neon.xl.animated.prefab" }, { "sign.neon.125x125", "assets/prefabs/misc/xmas/neon_sign/sign.neon.125x125.prefab" }, { "sign.neon.125x215", "assets/prefabs/misc/xmas/neon_sign/sign.neon.125x215.prefab" }, { "sign.neon.125x215.animated", "assets/prefabs/misc/xmas/neon_sign/sign.neon.125x215.animated.prefab" }, }; /// 帧比例组 → 动画霓虹变体(同 web:w2 横→125x215.animated,其余→xl.animated)。 private static string AnimPrefabFor(string group) => group == "w2" ? "sign.neon.125x215.animated" : "sign.neon.xl.animated"; private void KillPreview(BasePlayer player) { if (player == null) return; if (previewEnt.TryGetValue(player.userID, out var e)) { previewEnt.Remove(player.userID); if (e != null && !e.IsDestroyed) e.Kill(); } } /// 编辑已存方案:帧/源图按 cropId 回查素材库 → 预填草稿(EditId 非空=编辑态)→ 弹 CUI。 private IEnumerator OpenComposeEdit(BasePlayer player, string kind, string id) { if (!lastShown.TryGetValue(player.userID, out var st0) || st0 == null || st0.Count == 0) { var fetched = new List(); yield return GetJson($"/api/game/stickers?steamid={player.userID}", r => { if (r?.stickers != null) fetched = r.stickers; }); lastShown[player.userID] = fetched; } if (!lastAlbums.ContainsKey(player.userID)) yield return GetJson($"/api/game/albums?steamid={player.userID}", r => { if (r != null && r.ok) lastAlbums[player.userID] = r.albums ?? new List(); }); if (player == null || !player.IsConnected) yield break; if (!lastSets.TryGetValue(player.userID, out var sets) || sets == null) { Reply(player, "请重新打开 /img → 我的方案"); yield break; } var stickers = lastShown.TryGetValue(player.userID, out var sl) ? sl : new List(); Sticker byId(string cid) => stickers.Find(x => x.id == cid); var draft = new ComposeState { Kind = kind, EditId = id }; string albumId = null; if (kind == "anim") { var s = sets.animSets?.Find(x => x.id == id); if (s == null) { Reply(player, "方案不存在,请刷新"); yield break; } draft.EditName = s.name; draft.Speed = s.speed; draft.Brightness = s.brightness; albumId = s.albumId; if (s.frames != null) foreach (var f in s.frames) { var st1 = byId(f.cropId); if (st1 == null) continue; draft.Frames.Add(st1); draft.FrameB.Add(f.brightness > 0 ? f.brightness : s.brightness); } } else if (kind == "matrix") { var s = sets.matrixSets?.Find(x => x.id == id); if (s == null) { Reply(player, "方案不存在,请刷新"); yield break; } draft.EditName = s.name; draft.Cols = s.cols; draft.Rows = s.rows; draft.Brightness = s.brightness; draft.TilePrefab = (s.tilePrefab ?? "sign.neon.xl").Replace(".animated", ""); albumId = s.albumId; var src = string.IsNullOrEmpty(s.sourceCropId) ? null : byId(s.sourceCropId); if (src != null) { draft.Frames.Add(src); draft.FrameB.Add(s.brightness); } } else { var s = sets.animMatrixSets?.Find(x => x.id == id); if (s == null) { Reply(player, "方案不存在,请刷新"); yield break; } draft.EditName = s.name; draft.Cols = s.cols; draft.Rows = s.rows; draft.Speed = s.speed; draft.Brightness = s.brightness; draft.TilePrefab = (s.tilePrefab ?? "sign.neon.xl").Replace(".animated", ""); albumId = s.albumId; if (s.frameCropIds != null) for (int fi = 0; fi < s.frameCropIds.Count; fi++) { var st1 = byId(s.frameCropIds[fi]); if (st1 == null) continue; draft.Frames.Add(st1); int fb = s.frameBrightness != null && fi < s.frameBrightness.Count ? s.frameBrightness[fi] : s.brightness; draft.FrameB.Add(fb > 0 ? fb : s.brightness); } } if (draft.Frames.Count == 0) { NotifyErr(player, "源成品已不在素材库,无法编辑参数(可在网页工作台删除,或游戏内新建)"); yield break; } if (!string.IsNullOrEmpty(albumId) && lastAlbums.TryGetValue(player.userID, out var albs) && albs != null) draft.AlbumIdx = albs.FindIndex(a => a.id == albumId); composing[player.userID] = draft; ShowComposeCui(player); } /// 打开编排面板:确保 成品/相册 缓存就绪 → 初始化草稿 → 弹 CUI。 private IEnumerator OpenCompose(BasePlayer player) { if (!lastShown.TryGetValue(player.userID, out var st) || st == null || st.Count == 0) { var fetched = new List(); yield return GetJson($"/api/game/stickers?steamid={player.userID}", r => { if (r?.stickers != null) fetched = r.stickers; }); lastShown[player.userID] = fetched; } if (!lastAlbums.ContainsKey(player.userID)) yield return GetJson($"/api/game/albums?steamid={player.userID}", r => { if (r != null && r.ok) lastAlbums[player.userID] = r.albums ?? new List(); }); if (player == null || !player.IsConnected) yield break; if ((lastShown.TryGetValue(player.userID, out var sl) ? sl.Count : 0) == 0) { NotifyErr(player, "素材库还是空的——先去 ss.rustxz.com 上传裁图,再回来编排"); yield break; } if (!composing.ContainsKey(player.userID)) composing[player.userID] = new ComposeState(); ShowComposeCui(player); } /// 编排面板(坐标经 cui-preview 校验:fixtures/gen-smartsign-compose.mjs 同源镜像)。 private void ShowComposeCui(BasePlayer player) { var st = composing.TryGetValue(player.userID, out var s0) ? s0 : null; if (st == null) return; CuiHelper.DestroyUi(player, UiCompose); var c = new CuiElementContainer(); c.Add(new CuiElement { Name = UiCompose, Parent = "Overlay", Components = { new CuiImageComponent { Color = "0 0 0 0.62", Material = "assets/content/ui/uibackgroundblur-ingamemenu.mat" }, new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "1 1" }, new CuiNeedsCursorComponent(), }, }); Btn(c, UiCompose + ".veil", UiCompose, ColClear, "smartsign.compose.close", "0 0", "1 1", null, 0, "", ""); string P = UiCompose + ".card"; Rp(c, P, UiCompose, ColPanel, "0.22 0.12", "0.78 0.88", PanelWpx(0.22f, 0.78f), PanelHpx(0.12f, 0.88f), 10); // 顶栏(编辑态标原名) bool editing = !string.IsNullOrEmpty(st.EditId); Txt(c, P + ".title", P, editing ? $"编辑方案 · {Trunc(st.EditName ?? "", 14)}" : "新建方案 · 游戏内编排", 15, TextAnchor.MiddleLeft, ColInk, FontBold, "0.05 0.92", "0.7 0.985"); Btn(c, P + ".x", P, ColLine, "smartsign.compose.close", "0.92 0.925", "0.965 0.98", "✕", 13, ColSub, FontReg); Panel(c, P + ".topline", P, ColLine, "0 0.912", "1 0.914"); // 类型三选一(编辑态锁类型:非当前项变暗、点了也被 kind 命令拒) var kinds = new[] { new[] { "anim", "▶ 动图" }, new[] { "matrix", "▦ 矩阵" }, new[] { "animmatrix", "▶▦ 矩阵动图" } }; for (int i = 0; i < 3; i++) { bool on = st.Kind == kinds[i][0]; float x0 = 0.05f + i * 0.31f; Btn(c, P + $".kind{i}", P, on ? ColAccent : ColLine, $"smartsign.compose.kind {kinds[i][0]}", $"{F(x0)} 0.83", $"{F(x0 + 0.29f)} 0.895", kinds[i][1], 12, on ? ColInk : editing ? ColDimTxt : ColSub, on ? FontBold : FontReg); } bool isMatrix = st.Kind != "anim"; bool showFrames = st.Kind != "matrix"; // —— 左列:帧序列 / 源图 —— Txt(c, P + ".frames.lab", P, showFrames ? "帧序列(1–5 张同规格,顺序即播放序)" : "源图(切成整面墙)", 11, TextAnchor.MiddleLeft, ColDimTxt, FontReg, "0.05 0.765", "0.46 0.805"); int rowsShown = showFrames ? st.Frames.Count : Math.Min(st.Frames.Count, 1); for (int i = 0; i < rowsShown && i < 5; i++) { var fr = st.Frames[i]; int fb = i < st.FrameB.Count ? st.FrameB[i] : st.Brightness; float yMax = 0.755f - i * 0.107f, yMin = yMax - 0.095f; string row = P + $".fr{i}"; Rp(c, row, P, ColCard2, $"0.05 {F(yMin)}", $"0.46 {F(yMax)}", 0.41f * PanelWpx(0.22f, 0.78f), 0.095f * PanelHpx(0.12f, 0.88f), 5); // F3 恒正方小缩略(row=P 的 0.05-0.46 宽 × 0.095 高) SqThumbIn(c, row + ".th", row, fr.url, 0.02f, 0.08f, 0.2f, 0.92f, 0.41f * PanelWpx(0.22f, 0.78f), 0.095f * PanelHpx(0.12f, 0.88f)); Txt(c, row + ".n", row, Trunc(fr.name, 6), 11, TextAnchor.MiddleLeft, ColInk, FontReg, "0.24 0", "0.47 1"); // 每帧独立亮度:点 ☀ 循环 1→10(用户反馈:分开设置每张图亮度) Btn(c, row + ".fb", row, ColLine, $"smartsign.compose.fbright {i}", "0.48 0.16", "0.6 0.84", $"☀{fb}", 10, ColAccent, FontReg); if (showFrames) { Btn(c, row + ".up", row, ColLine, $"smartsign.compose.move {i} -1", "0.62 0.16", "0.72 0.84", "↑", 11, ColSub, FontReg); Btn(c, row + ".dn", row, ColLine, $"smartsign.compose.move {i} 1", "0.74 0.16", "0.84 0.84", "↓", 11, ColSub, FontReg); } Btn(c, row + ".rm", row, ColLine, $"smartsign.compose.rmframe {i}", "0.86 0.16", "0.96 0.84", "×", 11, ColBad, FontReg); } bool canAdd = showFrames ? st.Frames.Count < 5 : st.Frames.Count < 1; if (canAdd) Btn(c, P + ".addfr", P, ColAccentSoft, "smartsign.compose.pickframe", "0.05 0.145", "0.46 0.21", showFrames ? (st.Frames.Count == 0 ? "+ 选首帧" : "+ 加一帧") : "+ 选源图", 12, ColAccent, FontBold); // —— 右列:参数 —— if (isMatrix) { var tileLab = SignSizes.TryGetValue(st.TilePrefab, out var tsz) ? $"{st.TilePrefab.Replace("sign.neon.", "")}({tsz.W}×{tsz.H})" : st.TilePrefab; Txt(c, P + ".tile.lab", P, "单牌", 11, TextAnchor.MiddleLeft, ColDimTxt, FontReg, "0.53 0.71", "0.62 0.765"); Btn(c, P + ".tile.cyc", P, ColLine, "smartsign.compose.tile", "0.63 0.705", "0.95 0.77", tileLab + " ▸", 11, ColInk, FontReg); Txt(c, P + ".cols.lab", P, "长(横)", 11, TextAnchor.MiddleLeft, ColDimTxt, FontReg, "0.53 0.625", "0.62 0.68"); Btn(c, P + ".cols.sub", P, ColLine, "smartsign.compose.step c -1", "0.63 0.62", "0.70 0.685", "−", 12, ColSub, FontReg); Txt(c, P + ".cols.v", P, st.Cols.ToString(), 13, TextAnchor.MiddleCenter, ColInk, FontBold, "0.70 0.62", "0.78 0.685"); Btn(c, P + ".cols.add", P, ColLine, "smartsign.compose.step c 1", "0.78 0.62", "0.85 0.685", "+", 12, ColSub, FontReg); Txt(c, P + ".rows.lab", P, "宽(纵)", 11, TextAnchor.MiddleLeft, ColDimTxt, FontReg, "0.53 0.54", "0.62 0.595"); Btn(c, P + ".rows.sub", P, ColLine, "smartsign.compose.step r -1", "0.63 0.535", "0.70 0.6", "−", 12, ColSub, FontReg); Txt(c, P + ".rows.v", P, st.Rows.ToString(), 13, TextAnchor.MiddleCenter, ColInk, FontBold, "0.70 0.535", "0.78 0.6"); Btn(c, P + ".rows.add", P, ColLine, "smartsign.compose.step r 1", "0.78 0.535", "0.85 0.6", "+", 12, ColSub, FontReg); Txt(c, P + ".rows.n", P, $"= {st.Cols * st.Rows} 块", 11, TextAnchor.MiddleLeft, ColSub, FontReg, "0.86 0.535", "0.95 0.6"); } if (showFrames) { Txt(c, P + ".sp.lab", P, $"速度 · 每帧 {st.Speed:0.#} 秒", 11, TextAnchor.MiddleLeft, ColDimTxt, FontReg, "0.53 0.45", "0.95 0.5"); var speeds = new[] { 0.5f, 1f, 2f, 3f }; for (int i = 0; i < speeds.Length; i++) { bool on = Math.Abs(st.Speed - speeds[i]) < 0.01f; float x0 = 0.53f + i * 0.108f; Btn(c, P + $".sp{i}", P, on ? ColAccent : ColLine, $"smartsign.compose.speed {speeds[i].ToString(CultureInfo.InvariantCulture)}", $"{F(x0)} 0.385", $"{F(x0 + 0.096f)} 0.445", speeds[i].ToString("0.#") + "s", 11, on ? ColInk : ColSub, FontReg); } } Txt(c, P + ".br.lab", P, showFrames ? $"整体亮度 {st.Brightness}/10(改动=套用全帧;帧行 ☀ 单帧调)" : $"亮度 {st.Brightness}/10", 10, TextAnchor.MiddleLeft, ColDimTxt, FontReg, "0.53 0.30", "0.66 0.355"); Btn(c, P + ".br.sub", P, ColLine, "smartsign.compose.bright -1", "0.67 0.295", "0.74 0.36", "−", 12, ColSub, FontReg); Btn(c, P + ".br.add", P, ColLine, "smartsign.compose.bright 1", "0.76 0.295", "0.83 0.36", "+", 12, ColSub, FontReg); var albums = lastAlbums.TryGetValue(player.userID, out var al) ? al : null; string albumLab = st.AlbumIdx >= 0 && albums != null && st.AlbumIdx < albums.Count ? Trunc(albums[st.AlbumIdx].name, 10) : "不挂(仅工作台)"; Txt(c, P + ".alb.lab", P, "相册", 11, TextAnchor.MiddleLeft, ColDimTxt, FontReg, "0.53 0.215", "0.62 0.27"); Btn(c, P + ".alb.cyc", P, ColLine, "smartsign.compose.album", "0.63 0.21", "0.95 0.275", albumLab + " ▸", 11, ColInk, FontReg); // —— 底部动作(编辑态四钮:预览/另存为/保存修改/取消;新建三钮)—— Panel(c, P + ".botline", P, ColLine, "0 0.13", "1 0.132"); if (editing) { Btn(c, P + ".preview", P, ColLine, "smartsign.compose.preview", "0.05 0.04", "0.24 0.115", "👁 预览", 12, ColInk, FontBold); Btn(c, P + ".saveas", P, ColAccentSoft, "smartsign.compose.saveas", "0.27 0.04", "0.47 0.115", "另存为", 12, ColAccent, FontBold); Btn(c, P + ".save", P, ColAccent, "smartsign.compose.save", "0.5 0.04", "0.72 0.115", "保存修改", 13, ColInk, FontBold); Btn(c, P + ".cancel", P, ColLine, "smartsign.compose.close", "0.75 0.04", "0.95 0.115", "取消", 12, ColSub, FontReg); } else { Btn(c, P + ".preview", P, ColLine, "smartsign.compose.preview", "0.05 0.04", "0.3 0.115", "👁 面前预览", 12, ColInk, FontBold); Btn(c, P + ".save", P, ColAccent, "smartsign.compose.save", "0.36 0.04", "0.67 0.115", "保存方案", 13, ColInk, FontBold); Btn(c, P + ".cancel", P, ColLine, "smartsign.compose.close", "0.73 0.04", "0.95 0.115", "取消", 12, ColSub, FontReg); } CuiHelper.AddUi(player, c); } /// 选帧/源图叠层:列成品(第 2 帧起限同组);点一张加入。 private void ShowComposePicker(BasePlayer player) { var st = composing.TryGetValue(player.userID, out var s0) ? s0 : null; var stickers = lastShown.TryGetValue(player.userID, out var sl) ? sl : null; if (st == null || stickers == null) return; string needGroup = st.Kind != "matrix" && st.Frames.Count > 0 ? st.Frames[0].group : null; var pool = new List(); foreach (var x in stickers) if ((needGroup == null || x.group == needGroup) && !st.Frames.Exists(fx => fx.id == x.id)) pool.Add(x); CuiHelper.DestroyUi(player, UiComposePick); var c = new CuiElementContainer(); c.Add(new CuiElement { Name = UiComposePick, Parent = "Overlay", Components = { new CuiImageComponent { Color = "0 0 0 0.62", Material = "assets/content/ui/uibackgroundblur-ingamemenu.mat" }, new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "1 1" }, new CuiNeedsCursorComponent(), }, }); Btn(c, UiComposePick + ".veil", UiComposePick, ColClear, "smartsign.compose.pickclose", "0 0", "1 1", null, 0, "", ""); string P = UiComposePick + ".p"; Rp(c, P, UiComposePick, ColPanel, "0.2 0.14", "0.8 0.86", PanelWpx(0.2f, 0.8f), PanelHpx(0.14f, 0.86f), 10); Txt(c, P + ".title", P, st.Kind == "matrix" ? "选源图(将切成整面墙)" : st.Frames.Count == 0 ? "选首帧" : $"加一帧(限 {st.Frames[0].groupRatio} 同规格)", 15, TextAnchor.MiddleLeft, ColInk, FontBold, "0.04 0.9", "0.7 0.97"); Btn(c, P + ".x", P, ColLine, "smartsign.compose.pickclose", "0.9 0.905", "0.96 0.965", "✕", 13, ColSub, FontReg); if (pool.Count == 0) { Txt(c, P + ".empty", P, needGroup == null ? "素材库是空的~先去 ss.rustxz.com 裁图" : "没有更多同规格成品可加", 13, TextAnchor.MiddleCenter, ColDimTxt, FontReg, "0.04 0.1", "0.96 0.85"); CuiHelper.AddUi(player, c); return; } int n = Math.Min(pool.Count, GridMax); GridCardPx(0.04f, 0.96f, 0.05f, 0.87f, PanelWpx(0.2f, 0.8f), PanelHpx(0.14f, 0.86f), out var mcw, out var mch); for (int i = 0; i < n; i++) { var s = pool[i]; GridCell(i, 0.04f, 0.96f, 0.05f, 0.87f, out var min, out var max); string card = $"{P}.c{i}"; Rp(c, card, P, ColCard2, min, max, mcw, mch, 5); SqThumbIn(c, card + ".thumb", card, s.url, 0.06f, 0.28f, 0.94f, 0.93f, mcw, mch, imgW: s.w, imgH: s.h); // F3 恒正方 Txt(c, card + ".name", card, Trunc(s.name, 9), 11, TextAnchor.MiddleLeft, ColInk, FontBold, "0.07 0.05", "0.95 0.22"); Btn(c, card + ".pick", card, ColClear, $"smartsign.compose.addframe {s.id}", "0 0", "1 1", null, 0, "", ""); } CuiHelper.AddUi(player, c); } /// 实时预览(P3.3):面前 3m 临时 spawn 一块霓虹贴当前草稿(动画真跑、免电、不落盘),关面板/保存即销毁。 /// 矩阵类=单块整图近似预览(真瓦片保存后才物化)。🔒 朝向/高度真机校。 private IEnumerator ComposePreview(BasePlayer player) { var st = composing.TryGetValue(player.userID, out var s0) ? s0 : null; if (st == null) yield break; if (st.Frames.Count == 0) { Reply(player, "先选帧/源图再预览"); yield break; } string prefabShort = st.Kind == "anim" ? AnimPrefabFor(st.Frames[0].group) : st.TilePrefab; if (!NeonPrefabPath.TryGetValue(prefabShort, out var path)) { NotifyErr(player, "预览牌型不支持"); yield break; } KillPreview(player); var eyes = player.eyes; var fwd = eyes.HeadForward(); fwd.y = 0; fwd = fwd.normalized; var pos = eyes.position + fwd * 3f; var ent = GameManager.server.CreateEntity(path, pos, Quaternion.LookRotation(-fwd, Vector3.up)) as NeonSign; if (ent == null) { NotifyErr(player, "预览生成失败"); yield break; } ent.OwnerID = player.userID; ent.EnableSaving(false); // 纯临时:不进存档 ent.Spawn(); previewEnt[player.userID] = ent; var sz = SignSizes.TryGetValue(prefabShort, out var s1) ? s1 : new Sz(256, 256, "sq"); int slots = ent.textureIDs != null && ent.textureIDs.Length > 0 ? ent.textureIDs.Length : 1; int n = Math.Min(st.Frames.Count, slots); var pngs = new byte[slots][]; for (int f = 0; f < n; f++) { int fb = f < st.FrameB.Count ? st.FrameB[f] : st.Brightness; // 预览同样吃每帧亮度 byte[] png = null; yield return DownloadBytes(WithParams(st.Frames[f].url, $"w={sz.W}&h={sz.H}&b={fb}"), b => png = b); if (png == null) { NotifyErr(player, "预览帧下载失败"); KillPreview(player); yield break; } pngs[f] = png; } for (int f = n; f < slots; f++) pngs[f] = pngs[f % n]; if (ent == null || ent.IsDestroyed) yield break; try { WriteNeonFrames(ent, pngs); PowerNeon(ent, st.Kind == "matrix" ? 0f : st.Speed); } catch (Exception ex) { if (config.Debug) PrintWarning($"预览贴图失败: {ex.Message}"); } Reply(player, st.Kind == "anim" ? "预览已生成(面前 3 米,动画真跑);关面板即消失" : "预览已生成(单块整图近似;矩阵切块保存后游戏内贴才生效);关面板即消失"); } /// 保存草稿 → POST /api/game/sets → 关面板+销毁预览+刷新「我的方案」。 /// mode:create=新建 / update=保存修改(原 id) / copy=另存为(新建一份,名加「副本」)。 private IEnumerator ComposeSave(BasePlayer player, string mode) { var st = composing.TryGetValue(player.userID, out var s0) ? s0 : null; if (st == null) yield break; if (st.Frames.Count == 0) { Reply(player, st.Kind == "matrix" ? "还没选源图" : "还没选帧"); yield break; } var albums = lastAlbums.TryGetValue(player.userID, out var al) ? al : null; string albumId = st.AlbumIdx >= 0 && albums != null && st.AlbumIdx < albums.Count ? albums[st.AlbumIdx].id : null; string suffix = st.Kind == "anim" ? "动画" : st.Kind == "matrix" ? "矩阵" : "动画矩阵"; bool update = mode == "update" && !string.IsNullOrEmpty(st.EditId); string action = update ? "update" : "create"; string name = update ? st.EditName : mode == "copy" && !string.IsNullOrEmpty(st.EditName) ? $"{st.EditName} 副本" : $"{st.Frames[0].name} {suffix}"; string id = update ? st.EditId : null; object body; if (st.Kind == "anim") body = new { steamid = player.userID.ToString(), action, kind = "anim", id, name, frameCropIds = st.Frames.ConvertAll(x => x.id), frameBrightness = st.FrameB, speed = st.Speed, brightness = st.Brightness, prefab = AnimPrefabFor(st.Frames[0].group), albumId, }; else if (st.Kind == "matrix") body = new { steamid = player.userID.ToString(), action, kind = "matrix", id, name, cropId = st.Frames[0].id, cols = st.Cols, rows = st.Rows, tilePrefab = st.TilePrefab, brightness = st.Brightness, albumId, }; else body = new { steamid = player.userID.ToString(), action, kind = "animmatrix", id, name, frameCropIds = st.Frames.ConvertAll(x => x.id), cols = st.Cols, rows = st.Rows, frameBrightness = st.FrameB, tilePrefab = st.TilePrefab, speed = st.Speed, brightness = st.Brightness, albumId, }; SimpleResp r = null; yield return PostJson("/api/game/sets", JsonConvert.SerializeObject(body), x => r = x); if (r == null || !r.ok) { NotifyErr(player, r?.error ?? "保存失败(可能配额不足)"); yield break; } if (player == null || !player.IsConnected) yield break; KillPreview(player); composing.Remove(player.userID); CuiHelper.DestroyUi(player, UiComposePick); CuiHelper.DestroyUi(player, UiCompose); NotifyOk(player, update ? "修改已保存 · 游戏内/网页同步生效" : mode == "copy" ? $"已另存为「{name}」" : "方案已保存!在「我的方案」即可贴牌,网页工作台同步可见"); yield return OpenMain(player, "sets"); } // ======================= 智能实体接管:按 E 拦截 + TC 授权分支(S4.1) ======================= // ⚠️ 真机待验(🔒S4.4):①OnPlayerInput 每 tick 触发,已用 USE 边沿+冷却+早返收敛; // ②原生贴牌编辑器抑制策略(锁牌 SetFlag(Locked) vs CanUpdateSign hook)真机定,当前仅弹我方 CUI; // ③贴了图的牌=智能实体(本地 painted 映射),按 E 有授权→编辑 CUI、无授权→访客 CUI。 private void OnPlayerInput(BasePlayer player, InputState input) { if (player == null || input == null) return; if (!input.WasJustPressed(BUTTON.USE)) return; // F16:仅手持木锤/工具枪 → 放行原生旋转/升级/编辑,插件不接管。 // 蓝图 building.planner 不再放行——手持蓝图按 E 仍开插件 CUI(用户 2026-08-02 要求) var held = player.GetActiveItem()?.info?.shortname; if (held == "hammer" || held == "toolgun") return; float now = UnityEngine.Time.realtimeSinceStartup; if (lastInteract.TryGetValue(player.userID, out var t) && now - t < 0.4f) return; if (!GetLookedSign(player, out var sign)) return; var eid = sign.net.ID.Value.ToString(); if (!painted.TryGetValue(eid, out var info)) return; // 非智能实体 → 放行原生 lastInteract[player.userID] = now; bool authed = player.IsAdmin || info.Owner == player.userID.ToString() || PlayerCanEdit(sign, player); // 贴图者本人始终可编辑(不依赖 sign.OwnerID,防真锁试验遗留 OwnerID=0 误判访客) ServerMgr.Instance.StartCoroutine(OpenEntityCui(player, eid, info, authed)); } /// 拉 /image 拿实时名/赞/评/私密,再弹 编辑/访客 CUI。 private IEnumerator OpenEntityCui(BasePlayer player, string entityId, PaintedInfo info, bool authed) { ImageResp img = null; yield return GetJson($"/api/game/image/{info.CropId}?viewer={player.userID}", r => img = r); if (img == null || !img.ok || img.crop == null) { NotifyErr(player, "读取图信息失败"); yield break; } if (player == null || !player.IsConnected) yield break; entityCuiTarget[player.userID] = entityId; if (authed) ShowEditCui(player, info, img); else ShowVisitorCui(player, info, img); } /// E接管公共部分(v0.5.0 全屏,照 gen-smartsign-fs-entity.mjs):外壳 + 关闭条 + 左大图/名/作者·社交/说说 + 右栏。 private void FsEntityPage(CuiElementContainer c, BasePlayer player, PaintedInfo info, ImageResp img, bool owner) { var user = lastUser.TryGetValue(player.userID, out var u) ? u : null; BuildFsShell(c, player, user, "ent", UiEnt); FsBtn(c, UiEnt + ".back", UiEnt, FsCard, "smartsign.ent.close", FsX0, 0.862f, 0.11f, 0.908f, "✕ 关闭", FzBody, FsSub, FontReg); Txt(c, UiEnt + ".dh", UiEnt, owner ? "编辑智能牌" : "查看智能牌", FzLabel, TextAnchor.MiddleLeft, FsInk, FontBold, "0.12 0.862", "0.24 0.908"); Txt(c, UiEnt + ".dh2", UiEnt, owner ? "(这块牌子是你贴的)" : "(对准的牌 · 可赞可评)", FzSmall, TextAnchor.MiddleLeft, FsDim, FontReg, "0.24 0.864", "0.45 0.905"); string url = img.crop?.url; if (string.IsNullOrEmpty(url)) url = $"{config.ApiBase}/u/{info.CropId}.png"; FsBigImage(c, UiEnt, url, img.crop?.w ?? info.W, img.crop?.h ?? info.H, null); var ownerName = img.crop?.owner?.name; if (string.IsNullOrEmpty(ownerName)) ownerName = "玩家" + (info.Owner != null && info.Owner.Length >= 4 ? info.Owner.Substring(info.Owner.Length - 4) : info.Owner); var label = img.crop?.label ?? info.Prefab; Txt(c, UiEnt + ".n", UiEnt, Trunc(img.crop?.name ?? "", 16), FzTitle, TextAnchor.LowerLeft, FsInk, FontBold, "0.028 0.245", "0.52 0.295"); Txt(c, UiEnt + ".au", UiEnt, $"作者 {ownerName} · {label} · ♥ {img.likes?.total ?? 0} · 💬 {img.commentCount}", FzBody, TextAnchor.UpperLeft, FsSub, FontReg, "0.028 0.205", "0.52 0.243"); if (!string.IsNullOrEmpty(img.crop?.caption)) Txt(c, UiEnt + ".cp", UiEnt, "“" + Trunc(img.crop.caption, 20) + "”", FzBody, TextAnchor.UpperLeft, FsDim, FontReg, "0.028 0.17", "0.52 0.205"); } /// E接管·灯牌调整行(霓虹才显,照 fixture y 0.02-0.062):亮度 −/+(单牌,烘焙式)+ 速度 −/+(动图/动画墙)。 private void FsEntityAdjustRow(CuiElementContainer c, PaintedInfo info, NeonSign neon) { if (neon == null) return; bool isWall = !string.IsNullOrEmpty(info.Wall); bool anim = (neon.textureIDs != null && neon.textureIDs.Length > 1 && info.AllCrops != null && info.AllCrops.Count > 1) || (isWall && info.Spd > 0f); bool bright = !isWall; // 墙亮度在网页方案里调(重套用生效),单牌才给烘焙式调亮 if (!bright && !anim) return; Txt(c, UiEnt + ".lc", UiEnt, "灯牌调整", FzSmall, TextAnchor.MiddleLeft, FsDim, FontReg, "0.028 0.025", "0.09 0.06"); if (bright) { int b = info.B > 0 ? info.B : 6; Rp(c, UiEnt + ".bd", UiEnt, FsCard, "0.095 0.02", "0.24 0.062", 0.145f * ScrW, 0.042f * ScrH, 6); Btn(c, UiEnt + ".bd.m", UiEnt + ".bd", ColClear, "smartsign.ent.bright -1", "0 0", "0.3 1", "−", FzLabel, FsSub, FontBold); Txt(c, UiEnt + ".bd.v", UiEnt + ".bd", $"亮度 {b}", FzBody, TextAnchor.MiddleCenter, FsSub, FontReg, "0.3 0", "0.7 1"); Btn(c, UiEnt + ".bd.p", UiEnt + ".bd", ColClear, "smartsign.ent.bright 1", "0.7 0", "1 1", "+", FzLabel, FsSub, FontBold); } if (anim) { float sp = isWall ? info.Spd : (neon.animationSpeed > 0 ? neon.animationSpeed : 3f); Rp(c, UiEnt + ".sp", UiEnt, FsCard, "0.248 0.02", "0.39 0.062", 0.142f * ScrW, 0.042f * ScrH, 6); Btn(c, UiEnt + ".sp.m", UiEnt + ".sp", ColClear, "smartsign.ent.speed -1", "0 0", "0.3 1", "−", FzLabel, FsSub, FontBold); Txt(c, UiEnt + ".sp.v", UiEnt + ".sp", $"速度 {sp:0.#}s", FzBody, TextAnchor.MiddleCenter, FsSub, FontReg, "0.3 0", "0.7 1"); Btn(c, UiEnt + ".sp.p", UiEnt + ".sp", ColClear, "smartsign.ent.speed 1", "0.7 0", "1 1", "+", FzLabel, FsSub, FontBold); } } private void ShowEditCui(BasePlayer player, PaintedInfo info, ImageResp img) { CuiHelper.DestroyUi(player, UiEnt); var c = new CuiElementContainer(); FsEntityPage(c, player, info, img, owner: true); // owner 操作(照 fixture y 0.12-0.163 / 0.068-0.111) FsBtn(c, UiEnt + ".clr", UiEnt, FsAccSoft, "smartsign.ent.clear", 0.028f, 0.12f, 0.16f, 0.163f, "🗑 清除这块图", FzBody, FsAcc, FontBold); FsBtn(c, UiEnt + ".chg", UiEnt, FsCard, "smartsign.ent.repick", 0.168f, 0.12f, 0.32f, 0.163f, "🔄 换一张(回素材库)", FzBody, FsInk, FontBold); if (!img.isPrivate) { FsBtn(c, UiEnt + ".like", UiEnt, FsCard, "smartsign.ent.like", 0.028f, 0.068f, 0.11f, 0.111f, "♥ 赞", FzBody, FsInk, FontBold); FsBtn(c, UiEnt + ".cm", UiEnt, FsCard, "smartsign.ent.comment", 0.118f, 0.068f, 0.22f, 0.111f, "💬 评论", FzBody, FsInk, FontBold); } FsBtn(c, UiEnt + ".rp", UiEnt, FsCard, "smartsign.ent.report", 0.228f, 0.068f, 0.32f, 0.111f, "举报", FzBody, FsSub, FontBold); // 灯牌调整(霓虹才显) var neon = entityCuiTarget.TryGetValue(player.userID, out var eid) ? FindSign(eid) as NeonSign : null; FsEntityAdjustRow(c, info, neon); if (img.isPrivate) FsPrivateRightCol(c, UiEnt); else FsDetailRightCol(c, UiEnt, img, "smartsign.ent.comment", entityMode: true); CuiHelper.AddUi(player, c); } private void ShowVisitorCui(BasePlayer player, PaintedInfo info, ImageResp img) { CuiHelper.DestroyUi(player, UiEnt); var c = new CuiElementContainer(); FsEntityPage(c, player, info, img, owner: false); if (img.isPrivate) { FsBtn(c, UiEnt + ".rp", UiEnt, FsAccSoft, "smartsign.ent.report", 0.028f, 0.12f, 0.16f, 0.163f, "举报违规", FzBody, FsAcc, FontBold); Txt(c, UiEnt + ".pnote", UiEnt, "私密作品仅可举报", FzSmall, TextAnchor.MiddleLeft, FsDim, FontReg, "0.17 0.12", "0.4 0.163"); FsPrivateRightCol(c, UiEnt); } else { if (img.can?.like ?? false) FsBtn(c, UiEnt + ".like", UiEnt, FsCard, "smartsign.ent.like", 0.028f, 0.12f, 0.11f, 0.163f, "♥ 赞", FzBody, FsInk, FontBold); if (img.can?.comment ?? false) FsBtn(c, UiEnt + ".cm", UiEnt, FsCard, "smartsign.ent.comment", 0.118f, 0.12f, 0.22f, 0.163f, "💬 评论", FzBody, FsInk, FontBold); FsBtn(c, UiEnt + ".rp", UiEnt, FsCard, "smartsign.ent.report", 0.228f, 0.12f, 0.32f, 0.163f, "举报", FzBody, FsSub, FontBold); FsBtn(c, UiEnt + ".dt", UiEnt, FsCard, "smartsign.ent.detail", 0.028f, 0.068f, 0.16f, 0.111f, "🌐 广场详情", FzBody, FsSub, FontBold); FsDetailRightCol(c, UiEnt, img, "smartsign.ent.comment", entityMode: true); } CuiHelper.AddUi(player, c); } // ======================= 主 CUI(v0.5.0 全屏 web-mirror;坐标照抄 cui-preview fixtures gen-smartsign-fs*.mjs) ======================= private void ShowMainCui(BasePlayer player, UserResp user, string activeTab) { CuiHelper.DestroyUi(player, UiRoot); var c = new CuiElementContainer(); BuildFsShell(c, player, user, activeTab); // —— 内容区(按 tab 分发;全屏内容锚点 0.028-0.972 × 0.02-0.905,各 BuildFs* 直接挂 UiRoot)—— switch (activeTab) { case "sets": BuildFsStudio(c, player); break; case "albums": BuildFsAlbums(c, player); break; case "plaza": BuildFsPlaza(c, player); break; case "notif": BuildFsNotif(c, player); break; case "me": BuildFsMe(c, player, user); break; default: BuildFsLibrary(c, player, user); break; } CuiHelper.AddUi(player, c); } /// v0.5.0 全屏外壳:根(blur深底+光标) + 顶栏(logo+4tab+通知/用户/关闭)。内容区=全屏下方(约 0.028-0.972 × 0.02-0.905),调用方填。 /// root 可换(主界面 UiRoot / 详情 UiImg / E接管 UiEnt 都带同一外壳)。对齐官网 AppNav。 private void BuildFsShell(CuiElementContainer c, BasePlayer player, UserResp user, string activeTab, string root = UiRoot) { c.Add(new CuiElement { Name = root, Parent = "Overlay", Components = { new CuiImageComponent { Color = FsBg, Material = "assets/content/ui/uibackgroundblur-ingamemenu.mat" }, new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "1 1" }, new CuiNeedsCursorComponent(), }, }); Panel(c, root + ".nav", root, FsNav, "0 0.925", "1 1"); Panel(c, root + ".navln", root, FsLine, "0 0.9235", "1 0.925"); // 品牌 logo = 服务器 logo(启动脚本 server.logoimage,稳定托管 ss.rustxz.com/logo-256.png) float navH = (1f - 0.925f) * ScrH; // 顶栏参考px高 float lgav = 0.66f * navH; // logo 方形边长(点锚+对称px offset=跨分辨率恒方) string lg = root + ".logo"; RpOff(c, lg, root + ".nav", FsCard2, "0.029 0.5", "0.029 0.5", $"{F(-lgav / 2)} {F(-lgav / 2)}", $"{F(lgav / 2)} {F(lgav / 2)}", lgav, lgav, 6); RawImg(c, lg + ".im", lg, $"{config.ApiBase}/logo-256.png", "0 0", "1 1"); Txt(c, root + ".logo.t", root + ".nav", "智能贴图", FzNav, TextAnchor.MiddleLeft, FsInk, FontBold, "0.048 0", "0.2 1"); // 4 tab(游戏内砍掉网页专属「贴图工坊」) var nav = new[] { new[] { "mine", "素材库" }, new[] { "sets", "霓虹工作台" }, new[] { "albums", "我的相册" }, new[] { "plaza", "广场" } }; for (int i = 0; i < nav.Length; i++) { float tx = 0.25f + i * 0.092f; bool act = nav[i][0] == activeTab; Btn(c, root + ".tab" + i, root + ".nav", ColClear, "smartsign.tab " + nav[i][0], $"{F(tx)} 0", $"{F(tx + 0.092f)} 1", nav[i][1], FzTab, act ? FsAcc : FsSub, act ? FontBold : FontReg); if (act) Panel(c, root + ".u" + i, root + ".nav", FsAcc, $"{F(tx + 0.022f)} 0.09", $"{F(tx + 0.07f)} 0.125"); } int unread = lastNotif.TryGetValue(player.userID, out var nr) && nr != null ? nr.unread : 0; Btn(c, root + ".bell", root + ".nav", ColClear, "smartsign.tab notif", "0.8 0", "0.848 1", "🔔", FzNav, FsSub, FontReg); if (unread > 0) { Rp(c, root + ".bb", root + ".nav", FsAcc, "0.833 0.58", "0.849 0.82", 20f, 13f, 6); Txt(c, root + ".bb.t", root + ".bb", unread > 9 ? "9+" : unread.ToString(), FzTiny, TextAnchor.MiddleCenter, FsInk, FontBold, "0 0", "1 1"); } // 用户区:#4 头像(提示可点,先首字回退再叠真实 Steam 头像)+ 名字 + #6 me 激活橙条 + 透明点击层 bool meAct = activeTab == "me"; // 名字回退:平台 user 未加载时用 Rust 玩家名(避免顶栏显示「玩家」) string dispName = !string.IsNullOrEmpty(user?.name) ? user.name : (!string.IsNullOrEmpty(player?.displayName) ? player.displayName : "玩家"); float uav = 0.66f * navH; string ua = root + ".uav"; RpOff(c, ua, root + ".nav", FsThumb, "0.876 0.5", "0.876 0.5", $"{F(-uav / 2)} {F(-uav / 2)}", $"{F(uav / 2)} {F(uav / 2)}", uav, uav, 7); Txt(c, ua + ".t", ua, FirstChar(dispName), FzSmall, TextAnchor.MiddleCenter, FsSub, FontBold, "0 0", "1 1"); RawImg(c, ua + ".im", ua, $"{config.ApiBase}/api/avatar/{player.userID}", "0 0", "1 1"); Txt(c, root + ".uname", root + ".nav", Trunc(dispName, 5), FzBody, TextAnchor.MiddleLeft, meAct ? FsAcc : FsInk, meAct ? FontBold : FontReg, "0.897 0", "0.96 1"); if (meAct) Panel(c, root + ".uu", root + ".nav", FsAcc, "0.897 0.09", "0.945 0.125"); Btn(c, root + ".user", root + ".nav", ColClear, "smartsign.tab me", "0.86 0", "0.96 1", "", FzBody, FsInk, FontReg); Btn(c, root + ".x", root + ".nav", ColClear, "smartsign.close", "0.966 0.05", "0.998 0.95", "✕", 22, FsInk, FontBold); } // ============ v0.5.0 全屏 CUI 共用件(参考系=全屏 1280×720;坐标真相=fixtures gen-smartsign-fs*.mjs) ============ private const float ScrW = 1280f, ScrH = 720f; private const float FsX0 = 0.028f, FsX1 = 0.972f; // 内容左右边界(fs-shell shell() 返回值) private const float FsCw = 0.18f, FsGxg = 0.011f; // 5 列网格:卡宽/列隙(屏幕分数;(0.944-4*0.011)/5=0.18) private const float FsVpX1 = 0.99f; // 滚动视口右缘(右带=滚动条区) private const float FsVpW = FsVpX1 - FsX0; // 0.962 private const float FsCardWpx = FsCw * ScrW; // 230.4 卡参考px宽 private const float FsGygPx = 0.022f * ScrH; // 15.84 行隙参考px /// BetterTC 风格滚动视口(原生 CuiScrollViewComponent:Elastic+Inertia,圆角橙手柄+透明轨+Size20)。 /// 内容锚顶("0 1"→"1 1")、像素高 contentHpx;子元素挂 name 下(x 用分数锚、y 用自顶像素偏移)。 /// 锚串 "x y" 取 y 分数(顶对齐垫高用)。 private static float FsAnchorY(string anchor) { var p = anchor.Split(' '); return p.Length > 1 && float.TryParse(p[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y) ? y : 0f; } private void FsScroll(CuiElementContainer c, string name, string parent, string aMin, string aMax, float contentHpx) { // 顶部对齐修正:内容短于视口时 Unity ScrollRect 会把内容垂直居中(CuiRectTransform 无法设 pivot)。 // 把内容高垫到 ≥ 视口高(视口锚 y 分数 × 800 = 16:10 画布参考高)→ 内容≥视口→自然置顶。 // 16:10 屏精确(画布高恒 800);16:9 略过垫(+~10% 空滚动,无害);长内容 max() 不受影响。 float vpFrac = FsAnchorY(aMax) - FsAnchorY(aMin); if (vpFrac > 0) contentHpx = Mathf.Max(contentHpx, vpFrac * 800f); c.Add(new CuiElement { Name = name, Parent = parent, Components = { new CuiImageComponent { Color = ColClear }, new CuiScrollViewComponent { Horizontal = false, Vertical = true, MovementType = UnityEngine.UI.ScrollRect.MovementType.Elastic, Elasticity = 0.25f, Inertia = true, DecelerationRate = 0.3f, ScrollSensitivity = 24f, ContentTransform = new CuiRectTransform { AnchorMin = "0 1", AnchorMax = "1 1", OffsetMin = "0 -" + F(contentHpx), OffsetMax = "0 0" }, VerticalScrollbar = new CuiScrollbar { Invert = false, AutoHide = false, HandleSprite = "assets/content/ui/ui.rounded.tga", HandleColor = FsSbHandle, HighlightColor = FsAcc, PressedColor = FsAcc, TrackSprite = "assets/content/ui/ui.background.tile.psd", TrackColor = "0 0 0 0", Size = 20, }, }, new CuiRectTransformComponent { AnchorMin = aMin, AnchorMax = aMax }, }, }); } /// 圆角面板(锚点+像素偏移版 Rp;滚动内容里的卡用:x 分数锚、y 自顶像素偏移)。 private void RpOff(CuiElementContainer c, string name, string parent, string color, string aMin, string aMax, string oMin, string oMax, float refW, float refH, int radius) { c.Add(new CuiElement { Name = name, Parent = parent, Components = { new CuiRawImageComponent { Png = RoundTex(Mathf.RoundToInt(refW), Mathf.RoundToInt(refH), radius), Color = color }, new CuiRectTransformComponent { AnchorMin = aMin, AnchorMax = aMax, OffsetMin = oMin, OffsetMax = oMax }, }, }); } /// 根级圆角按钮(屏幕分数 → 参考px 自动折算;color=Clear 时无底纯命令)。 private void FsBtn(CuiElementContainer c, string name, string parent, string color, string cmd, float x0, float y0, float x1, float y1, string label, int size, string textColor, string font, int radius = 6) { if (color != ColClear) Rp(c, name + ".bg", parent, color, $"{F(x0)} {F(y0)}", $"{F(x1)} {F(y1)}", (x1 - x0) * ScrW, (y1 - y0) * ScrH, radius); Btn(c, name, parent, ColClear, cmd, $"{F(x0)} {F(y0)}", $"{F(x1)} {F(y1)}", null, 0, "", ""); if (!string.IsNullOrEmpty(label)) Txt(c, name + ".t", name, label, size, TextAnchor.MiddleCenter, textColor, font, "0 0", "1 1"); } /// 页头:标题(FzTitle bold)+ 可选副题(FzBody sub)。x 定位照各 fixture。 private void FsHeader(CuiElementContainer c, string title, float titleX1, string subtitle, float subX0, float subX1) { Txt(c, UiRoot + ".h", UiRoot, title, FzTitle, TextAnchor.MiddleLeft, FsInk, FontBold, $"{F(FsX0)} 0.852", $"{F(titleX1)} 0.912"); if (!string.IsNullOrEmpty(subtitle)) Txt(c, UiRoot + ".hs", UiRoot, subtitle, FzBody, TextAnchor.MiddleLeft, FsSub, FontReg, $"{F(subX0)} 0.858", $"{F(subX1)} 0.905"); } /// letterbox 缩略:圆角深底 tb + 按 imgW/imgH vs boxAR 居中内嵌 RawImage(fixture ⑥b 同算法)。 /// tbY0/iy0/iy1/boxAR 各屏常量照 fixture;无 url 显占位字。 private void FsLbThumb(CuiElementContainer c, string card, string url, float tbY0, float iy0, float iy1, float boxAR, int imgW, int imgH, float cardWpx, float cardHpx, string ph = null) { Rp(c, card + ".tb", card, FsThumb, $"0.05 {F(tbY0)}", "0.95 0.955", 0.90f * cardWpx, (0.955f - tbY0) * cardHpx, 7); float ix0 = 0.07f, ix1 = 0.93f; float ar = imgW > 0 && imgH > 0 ? (float)imgW / imgH : boxAR; if (ar > boxAR) { float hh = (iy1 - iy0) * (boxAR / ar); float cy = (iy0 + iy1) / 2f; iy0 = cy - hh / 2f; iy1 = cy + hh / 2f; } else if (ar < boxAR) { float ww = (ix1 - ix0) * (ar / boxAR); float cx = (ix0 + ix1) / 2f; ix0 = cx - ww / 2f; ix1 = cx + ww / 2f; } if (!string.IsNullOrEmpty(url)) RawImg(c, card + ".im", card, url, $"{F(ix0)} {F(iy0)}", $"{F(ix1)} {F(iy1)}"); else { Panel(c, card + ".im", card, FsCard2, $"{F(ix0)} {F(iy0)}", $"{F(ix1)} {F(iy1)}"); if (!string.IsNullOrEmpty(ph)) Txt(c, card + ".imt", card + ".im", ph, FzSmall, TextAnchor.MiddleCenter, FsDim, FontReg, "0 0", "1 1"); } } /// 方形封面缩略(相册/广场卡):卡内点锚方框(点锚 0.5 1 顶中 + 方形 px offset,跨分辨率恒方, /// 修 16:10 屏封面被压扁)+ letterbox 内嵌封面(imgW/H>0 按比例;否则占位)。sidePx=边长(参考px)、topPx=距卡顶留白。 private void FsSquareThumb(CuiElementContainer c, string card, string url, float sidePx, float topPx, int imgW, int imgH, string ph) { string tb = card + ".tb"; float half = sidePx / 2f; RpOff(c, tb, card, FsThumb, "0.5 1", "0.5 1", $"{F(-half)} {F(-topPx - sidePx)}", $"{F(half)} {F(-topPx)}", sidePx, sidePx, 7); float ix0 = 0.05f, iy0 = 0.05f, ix1 = 0.95f, iy1 = 0.95f; // 方框内 letterbox(子锚 0-1 of tb) float ar = imgW > 0 && imgH > 0 ? (float)imgW / imgH : 1f; if (ar > 1f) { float hh = (iy1 - iy0) / ar; iy0 = 0.5f - hh / 2f; iy1 = 0.5f + hh / 2f; } else if (ar < 1f) { float ww = (ix1 - ix0) * ar; ix0 = 0.5f - ww / 2f; ix1 = 0.5f + ww / 2f; } if (!string.IsNullOrEmpty(url)) RawImg(c, card + ".im", tb, url, $"{F(ix0)} {F(iy0)}", $"{F(ix1)} {F(iy1)}"); else { Panel(c, card + ".im", tb, FsCard2, $"{F(ix0)} {F(iy0)}", $"{F(ix1)} {F(iy1)}"); if (!string.IsNullOrEmpty(ph)) Txt(c, card + ".imt", tb, ph, FzSmall, TextAnchor.MiddleCenter, FsDim, FontReg, "0 0", "1 1"); } } /// 5 列网格卡外框(滚动内容里:x 分数锚 / y 自顶像素偏移),返回卡名。i 从 0 起。 private string FsGridCard(CuiElementContainer c, string scroll, string prefix, int i, float cardHpx) { int col = i % 5, row = i / 5; float xv0 = col * (FsCw + FsGxg) / FsVpW, xv1 = xv0 + FsCw / FsVpW; float top = row * (cardHpx + FsGygPx); string card = $"{prefix}{i}"; RpOff(c, card, scroll, FsCard, $"{F(xv0)} 1", $"{F(xv1)} 1", $"0 {F(-(top + cardHpx))}", $"0 {F(-top)}", FsCardWpx, cardHpx, 10); return card; } /// 网格内容像素高(n 项 → 行数×(卡高+隙)−隙;至少一行)。 private static float FsGridContentH(int n, float cardHpx) { int rows = Mathf.Max(1, Mathf.CeilToInt(n / 5f)); return rows * (cardHpx + FsGygPx) - FsGygPx; } /// 内容区居中提示(空态/加载态;全屏内容区)。 private void FsNote(CuiElementContainer c, string text) { Txt(c, UiRoot + ".note", UiRoot, text, FzLabel, TextAnchor.MiddleCenter, FsDim, FontReg, $"{F(FsX0)} 0.06", $"{F(FsX1)} 0.78"); } /// 贴纸卡(素材库版式,照 gen-smartsign-fs.mjs:letterbox 缩略 + 名/说说/牌型·比例/♥评)。 /// 整卡点击 → clickCmd;返回卡名(调用方可在其上叠加按钮,后加者优先命中)。 private string FsStickerCard(CuiElementContainer c, string scroll, string prefix, int i, Sticker s, float cardHpx, string clickCmd) { float boxAR = 0.86f * FsCardWpx / (0.605f * cardHpx); string card = FsGridCard(c, scroll, prefix, i, cardHpx); FsLbThumb(c, card, s.url, 0.335f, 0.35f, 0.94f, boxAR, s.w, s.h, FsCardWpx, cardHpx); Txt(c, card + ".n", card, Trunc(s.name, 12), FzBody, TextAnchor.LowerLeft, FsInk, FontBold, "0.06 0.24", "0.94 0.32"); if (!string.IsNullOrEmpty(s.caption)) Txt(c, card + ".cp", card, Trunc(s.caption, 16), FzSmall, TextAnchor.UpperLeft, FsDim, FontReg, "0.06 0.15", "0.94 0.235"); var meta = string.IsNullOrEmpty(s.groupRatio) ? (s.label ?? "") : $"{s.label} · {s.groupRatio}"; Txt(c, card + ".mt", card, Trunc(meta, 14), FzSmall, TextAnchor.LowerLeft, FsSub, FontReg, "0.06 0.05", "0.6 0.125"); Txt(c, card + ".so", card, $"♥ {s.likes} 💬 {s.comments}", FzSmall, TextAnchor.LowerRight, FsDim, FontReg, "0.45 0.05", "0.94 0.125"); if (!string.IsNullOrEmpty(clickCmd)) Btn(c, card + ".go", card, ColClear, clickCmd, "0 0", "1 1", null, 0, "", ""); return card; } private static string FsSupLabel(ImgSupport s) => s.w == s.h ? $"{s.label} {s.w}²" : $"{s.label} {s.w}×{s.h}"; /// 详情/E接管 左栏大图盒(照 fs-detail/fs-entity:0.028-0.52 × 0.30-0.845 圆角深底 + letterbox 内嵌),可选点图看大图。 private void FsBigImage(CuiElementContainer c, string root, string url, int w, int h, string zoomCmd) { Rp(c, root + ".imbox", root, FsThumb, "0.028 0.30", "0.52 0.845", 0.492f * ScrW, 0.545f * ScrH, 12); // 图片:点锚+对称px offset 恒比例(修 16:10 方形被拉伸)——盒内 contain 居中 float boxWpx = 0.482f * ScrW, boxHpx = 0.525f * ScrH; // 盒内可用区(略去内边距,参考px) float ar = w > 0 && h > 0 ? (float)w / h : boxWpx / boxHpx; float iw = boxWpx, ih = boxHpx; if (ar > boxWpx / boxHpx) ih = boxWpx / ar; else iw = boxHpx * ar; const float cx = 0.274f, cy = 0.5725f; // 盒中心(分数) if (!string.IsNullOrEmpty(url)) c.Add(new CuiElement { Name = root + ".im", Parent = root, Components = { new CuiRawImageComponent { Url = url }, new CuiRectTransformComponent { AnchorMin = $"{F(cx)} {F(cy)}", AnchorMax = $"{F(cx)} {F(cy)}", OffsetMin = $"{F(-iw / 2f)} {F(-ih / 2f)}", OffsetMax = $"{F(iw / 2f)} {F(ih / 2f)}" }, }, }); if (!string.IsNullOrEmpty(zoomCmd)) Btn(c, root + ".imz", root, ColClear, zoomCmd, "0.028 0.30", "0.52 0.845", null, 0, "", ""); } private static string FirstChar(string s) => string.IsNullOrEmpty(s) ? "玩" : s.Substring(0, 1); /// 适用牌型 chip(滚动内容内,2 列:方图标点锚+offset + 官方名)。i 从 0 起。 private void FsSupChip(CuiElementContainer c, string scroll, int i, ImgSupport s, float vpWpx, float chipHpx) { float colFrac = 0.46f, gapFrac = 0.03f, rowGapPx = 0.012f * ScrH; int col = i % 2, row = i / 2; float x0 = col * (colFrac + gapFrac), x1 = x0 + colFrac; float top = row * (chipHpx + rowGapPx); float chipWpx = colFrac * vpWpx; string chip = $"{scroll}.sc{i}"; RpOff(c, chip, scroll, FsCard, $"{F(x0)} 1", $"{F(x1)} 1", $"0 {F(-(top + chipHpx))}", $"0 {F(-top)}", chipWpx, chipHpx, 6); float icon = 0.70f * chipHpx, mx = 0.10f * chipHpx; if (!string.IsNullOrEmpty(s.prefab)) // 方图标:点锚左中 + 方形 offset(CuiLayout.Square,跨分辨率不变形) c.Add(new CuiElement { Name = chip + ".ic", Parent = chip, Components = { new CuiRawImageComponent { Url = $"{config.ApiBase}/signs/{s.prefab}.png" }, new CuiRectTransformComponent { AnchorMin = "0 0.5", AnchorMax = "0 0.5", OffsetMin = $"{F(mx)} {F(-icon / 2)}", OffsetMax = $"{F(mx + icon)} {F(icon / 2)}" }, }, }); float labX0 = (mx + icon + 0.06f * chipHpx) / chipWpx; // 反馈:牌名字号 +4px(9→13)、去掉名后尺寸数字(只留官方名) Txt(c, chip + ".t", chip, s.label ?? "", FzTiny + 4, TextAnchor.MiddleLeft, FsSub, FontReg, $"{F(labX0)} 0", "0.96 1"); } /// 详情/E接管 右栏(照 fs-detail / fs-entity):适用牌型(图标+官方名,全列出不省略,纵向滚动) /// + 评论区(原生滚动·顶对齐·真实头像)+ 写评论/发送。entityMode 仅微调纵向边界。 private void FsDetailRightCol(CuiElementContainer c, string root, ImageResp img, string commentCmd, bool entityMode) { Txt(c, root + ".sh", root, "适用牌型", FzLabel, TextAnchor.MiddleLeft, FsAcc, FontBold, "0.55 0.855", "0.75 0.9"); Panel(c, root + ".shl", root, FsLine, "0.55 0.85", "0.985 0.852"); var sup = img.supports; int sn = sup?.Count ?? 0; float supBot = entityMode ? 0.60f : 0.56f; // 适用牌型滚动区下边界 if (sn == 0) { Rp(c, root + ".sc0", root, FsCard, "0.55 0.792", "0.72 0.83", 0.17f * ScrW, 0.038f * ScrH, 6); Txt(c, root + ".sc0.t", root + ".sc0", img.crop?.label ?? "本组牌型", FzSmall, TextAnchor.MiddleCenter, FsSub, FontReg, "0.04 0", "0.96 1"); } else { float chipHpx = 0.052f * ScrH, rowGapPx = 0.012f * ScrH, vpWpx = 0.435f * ScrW; int rows = Mathf.CeilToInt(sn / 2f); float contentH = rows * (chipHpx + rowGapPx) - rowGapPx; string sv = root + ".sv"; FsScroll(c, sv, root, $"0.55 {F(supBot)}", "0.985 0.845", contentH); for (int i = 0; i < sn; i++) FsSupChip(c, sv, i, sup[i], vpWpx, chipHpx); } float chHY = entityMode ? 0.555f : 0.50f; // 评论区 header 底 y float clY = chHY - 0.008f, cvTop = chHY - 0.02f; // 分隔线 / 评论滚动顶边界 Txt(c, root + ".ch", root, $"评论区({img.commentCount})", FzLabel, TextAnchor.MiddleLeft, FsAcc, FontBold, $"0.55 {F(chHY)}", $"0.8 {F(chHY + 0.045f)}"); Panel(c, root + ".chl", root, FsLine, $"0.55 {F(clY)}", $"0.985 {F(clY + 0.002f)}"); var cms = img.comments; if (cms == null || cms.Count == 0) { // 空态顶对齐(贴分隔线下方) Txt(c, root + ".cmempty", root, "还没有评论~ 点下方「写条评论」发第一条", FzBody, TextAnchor.UpperLeft, FsDim, FontReg, $"0.55 {F(cvTop - 0.055f)}", $"0.965 {F(cvTop - 0.005f)}"); } else { float rowHpx = 0.078f * ScrH, rowGapPx = 0.012f * ScrH; int n = Math.Min(cms.Count, 30); float contentH = n * (rowHpx + rowGapPx) - rowGapPx; string cv = root + ".cv"; FsScroll(c, cv, root, "0.55 0.10", $"0.985 {F(cvTop)}", contentH); float rowW = (0.965f - 0.55f) / (0.985f - 0.55f); float rowWpx = 0.415f * ScrW; for (int i = 0; i < n; i++) { var cm = cms[i]; float yTop = i * (rowHpx + rowGapPx); string row = $"{cv}.cm{i}"; RpOff(c, row, cv, FsCard, "0 1", $"{F(rowW)} 1", $"0 {F(-(yTop + rowHpx))}", $"0 {F(-yTop)}", rowWpx, rowHpx, 7); // 头像:方形点锚 + 真实 Steam 头像(先画首字回退,再叠 RawImage;图 404/失败也露出首字) float av = 0.60f * rowHpx, avx = 0.03f * rowWpx; string abox = row + ".a"; RpOff(c, abox, row, FsCard2, "0 0.5", "0 0.5", $"{F(avx)} {F(-av / 2)}", $"{F(avx + av)} {F(av / 2)}", av, av, 6); Txt(c, abox + ".t", abox, FirstChar(cm.author), FzBody, TextAnchor.MiddleCenter, FsSub, FontBold, "0 0", "1 1"); if (!string.IsNullOrEmpty(cm.steamId)) RawImg(c, abox + ".im", abox, $"{config.ApiBase}/api/avatar/{cm.steamId}", "0 0", "1 1"); float tx0 = (avx + av + 0.025f * rowWpx) / rowWpx; Txt(c, row + ".au", row, Trunc(cm.author ?? "玩家", 12), FzBody, TextAnchor.UpperLeft, FsInk, FontBold, $"{F(tx0)} 0.5", "0.97 0.94"); Txt(c, row + ".tx", row, Trunc(cm.text ?? "", 30), FzSmall, TextAnchor.UpperLeft, FsSub, FontReg, $"{F(tx0)} 0.08", "0.97 0.5"); } } // 写评论 + 发送(点任一都弹输入层) Rp(c, root + ".ci", root, FsCard, "0.55 0.04", "0.88 0.088", 0.33f * ScrW, 0.048f * ScrH, 8); Txt(c, root + ".ci.t", root + ".ci", "写条评论…", FzBody, TextAnchor.MiddleLeft, FsDim, FontReg, "0.03 0", "0.97 1"); Btn(c, root + ".ci.b", root, ColClear, commentCmd, "0.55 0.04", "0.88 0.088", null, 0, "", ""); FsBtn(c, root + ".cis", root, FsAcc, commentCmd, 0.89f, 0.04f, 0.965f, 0.088f, "发送", FzBody, FsInk, FontBold); } /// 详情/E接管 右栏私密占位(右侧无社交)。 private void FsPrivateRightCol(CuiElementContainer c, string root) { Txt(c, root + ".pvt", root, "🔒 私密作品\n\n未公开到广场,仅可举报。\n公开到相册后才有评论与社交。", FzBody, TextAnchor.MiddleCenter, FsDim, FontReg, "0.55 0.2", "0.965 0.75"); } // —— 六页全屏内容(照 fixtures 逐屏移植) —— /// 素材库(照 gen-smartsign-fs.mjs):页头统计+搜索/排序/扩容 + 成品/原图分段 + 5列滚动网格,点卡=详情。 private void BuildFsLibrary(CuiElementContainer c, BasePlayer player, UserResp user) { var st = fsLib.TryGetValue(player.userID, out var s0) ? s0 : (fsLib[player.userID] = new FsLibState()); var all = lastShown.TryGetValue(player.userID, out var sl) ? sl : null; int total = all?.Count ?? 0; SetGridCtx(player, "mine"); var quotaMb = user?.quota != null ? user.quota.used / 1048576.0 : 0; var totalMb = user?.quota != null ? user.quota.quota / 1048576.0 : 0; FsHeader(c, "素材库", 0.16f, $"{total} 张成品 · {quotaMb:0.0} / {totalMb:0} MB", 0.105f, 0.42f); // 搜索 / 排序 / 扩容(fixture 行 y 0.858-0.905) string q = string.IsNullOrEmpty(st.Query) ? null : st.Query; bool segRaw = st.Seg == 1; FsBtn(c, UiRoot + ".sr", UiRoot, FsCard, "smartsign.lib.search", 0.44f, 0.858f, 0.63f, 0.905f, null, 0, "", ""); Txt(c, UiRoot + ".sr.t", UiRoot + ".sr", q == null ? "🔍 搜名字、牌型…" : $"🔍 {Trunc(q, 10)}", FzBody, TextAnchor.MiddleLeft, q == null ? FsDim : FsInk, FontReg, "0.03 0", q == null ? "0.97 1" : "0.82 1"); if (q != null) // 搜索态:右端 ✕ 清除(空输入框回车不提交,故用独立钮清空,见 smartsign.lib.clearsearch) Btn(c, UiRoot + ".sr.clr", UiRoot + ".sr", ColClear, "smartsign.lib.clearsearch", "0.84 0.12", "0.99 0.88", "✕", FzBody, FsAcc, FontBold); // 按牌型筛选入口(搜索框同行;仅成品页) if (!segRaw) FsSignFilterBtn(c, player, all, 0.635f, 0.858f, 0.775f, 0.905f); FsBtn(c, UiRoot + ".st", UiRoot, FsCard, "smartsign.lib.sort", 0.78f, 0.858f, 0.875f, 0.905f, st.Sort == 0 ? "最新优先 ▾" : "最热优先 ▾", FzBody, FsSub, FontReg); FsBtn(c, UiRoot + ".eb", UiRoot, FsAccSoft, "smartsign.lib.buy", 0.88f, 0.858f, 0.972f, 0.905f, "+ 扩容", FzBody, FsAcc, FontBold); // 成品/原图 分段(y 0.802-0.842) FsBtn(c, UiRoot + ".tf", UiRoot, segRaw ? ColClear : FsAccSoft, "smartsign.lib.seg 0", FsX0, 0.802f, 0.088f, 0.842f, $"成品 {total}", FzBody, segRaw ? FsSub : FsAcc, segRaw ? FontReg : FontBold); FsBtn(c, UiRoot + ".to", UiRoot, segRaw ? FsAccSoft : ColClear, "smartsign.lib.seg 1", 0.094f, 0.802f, 0.156f, 0.842f, "原图", FzBody, segRaw ? FsAcc : FsSub, segRaw ? FontBold : FontReg); if (segRaw) { FsNote(c, "原图(未裁剪的上传)只在网页管理~\n上传 / 裁剪 / 删除请到 ss.rustxz.com「素材库」"); return; } // 过滤(牌型)+ 搜索 + 排序 var list = FilterBySign(player, all != null ? new List(all) : new List()); if (q != null) list = list.FindAll(x => (x.name != null && x.name.Contains(q)) || (x.label != null && x.label.Contains(q))); if (st.Sort == 1) list.Sort((a, b) => b.likes.CompareTo(a.likes)); if (list.Count == 0) { FsNote(c, q != null ? $"没有找到「{Trunc(q, 12)}」相关的成品~\n点搜索框修改,或输入 - 清空搜索" : "还没有成品~\n先去 ss.rustxz.com 上传图片、裁成牌型,再回来 /img 贴牌"); return; } // 5列滚动网格(视口 y 0.04-0.786,卡高 0.362×720) float cardHpx = 0.362f * ScrH; int n = Math.Min(list.Count, 30); FsScroll(c, UiRoot + ".gv", UiRoot, $"{F(FsX0)} 0.04", $"{F(FsVpX1)} 0.786", FsGridContentH(n, cardHpx)); for (int i = 0; i < n; i++) FsStickerCard(c, UiRoot + ".gv", UiRoot + ".c", i, list[i], cardHpx, $"smartsign.plaza.img {list[i].id} mine"); if (list.Count > n) Txt(c, UiRoot + ".more", UiRoot, $"还有 {list.Count - n} 张 · 更多请上 ss.rustxz.com", FzSmall, TextAnchor.MiddleCenter, FsDim, FontReg, $"{F(FsX0)} 0.008", $"{F(FsX1)} 0.038"); } /// 霓虹工作台(照 gen-smartsign-fs-studio.mjs):类型分段(动图/矩阵/矩阵动图) + 方案卡网格(kind徽标+名+规格+帧块)。 /// 点卡=套用(smartsign.set.pick 原贴牌流)。游戏内无新建/编排入口——创建走网页。 private void BuildFsStudio(CuiElementContainer c, BasePlayer player) { var resp = lastSets.TryGetValue(player.userID, out var s) ? s : null; int na = resp?.animSets?.Count ?? 0, nm = resp?.matrixSets?.Count ?? 0, nam = resp?.animMatrixSets?.Count ?? 0; int kind = fsSetsKind.TryGetValue(player.userID, out var k) ? k : 0; FsHeader(c, "霓虹工作台", 0.22f, "选中方案 → 对准霓虹灯牌套用 · 新建/编排请去网页 ss.rustxz.com", 0.15f, 0.72f); // 类型分段(y 0.80-0.842) var kinds = new[] { $"▶ 动图 {na}", $"▦ 矩阵 {nm}", $"▶▦ 矩阵动图 {nam}" }; for (int i = 0; i < 3; i++) { float tx = FsX0 + i * 0.105f; bool act = i == kind; FsBtn(c, UiRoot + ".kt" + i, UiRoot, act ? FsAccSoft : FsCard, "smartsign.sets.kind " + i, tx, 0.80f, tx + 0.10f, 0.842f, kinds[i], FzBody, act ? FsAcc : FsSub, act ? FontBold : FontReg); } // 当前类型的卡列表:[id, 徽标, 名, 规格(label), 帧/块 meta, 缩略url] var cards = new List(); if (kind == 0 && resp?.animSets != null) foreach (var a in resp.animSets) cards.Add(new[] { "anim", a.id, "▶ 动图", a.name, a.label ?? "动画霓虹", $"{(a.frames?.Count ?? 0)} 帧 × {a.speed:0.#}s", a.frames != null && a.frames.Count > 0 ? a.frames[0].url : null }); else if (kind == 1 && resp?.matrixSets != null) foreach (var m in resp.matrixSets) cards.Add(new[] { "matrix", m.id, "▦ 矩阵", m.name, m.label ?? "霓虹矩阵墙", $"{m.cols}×{m.rows} = {m.cols * m.rows} 块", m.tiles != null && m.tiles.Count > 0 ? m.tiles[0] : null }); else if (kind == 2 && resp?.animMatrixSets != null) foreach (var am in resp.animMatrixSets) cards.Add(new[] { "animmatrix", am.id, "▶▦ 矩阵动图", am.name, am.label ?? "矩阵动图", $"{am.frameCount} 帧 · {am.cols}×{am.rows} = {am.cols * am.rows} 块", am.tilesByFrame != null && am.tilesByFrame.Count > 0 && am.tilesByFrame[0].Count > 0 ? am.tilesByFrame[0][0] : null }); if (cards.Count == 0) { FsNote(c, $"还没有{(kind == 0 ? "动图" : kind == 1 ? "矩阵" : "矩阵动图")}方案~\n去网页 ss.rustxz.com「霓虹工作台」编排,保存后回这里对准灯牌套用"); return; } float cardHpx = 0.362f * ScrH; float boxAR = 0.86f * FsCardWpx / (0.575f * cardHpx); int n = Math.Min(cards.Count, 30); FsScroll(c, UiRoot + ".gv", UiRoot, $"{F(FsX0)} 0.04", $"{F(FsVpX1)} 0.786", FsGridContentH(n, cardHpx)); for (int i = 0; i < n; i++) { var it = cards[i]; string card = FsGridCard(c, UiRoot + ".gv", UiRoot + ".s", i, cardHpx); FsLbThumb(c, card, it[6], 0.36f, 0.37f, 0.945f, boxAR, 0, 0, FsCardWpx, cardHpx, "方案预览"); Rp(c, card + ".kb", card, FsAccSoft, "0.07 0.865", "0.42 0.93", 0.35f * FsCardWpx, 0.065f * cardHpx, 5); Txt(c, card + ".kbt", card + ".kb", it[2], FzTiny, TextAnchor.MiddleCenter, FsAcc, FontBold, "0.06 0", "0.94 1"); Txt(c, card + ".n", card, Trunc(it[3], 12), FzBody, TextAnchor.LowerLeft, FsInk, FontBold, "0.06 0.255", "0.94 0.34"); Txt(c, card + ".sp", card, Trunc(it[4], 16), FzSmall, TextAnchor.LowerLeft, FsSub, FontReg, "0.06 0.155", "0.94 0.235"); Txt(c, card + ".m", card, it[5], FzSmall, TextAnchor.LowerLeft, FsDim, FontReg, "0.06 0.05", "0.94 0.13"); Btn(c, card + ".go", card, ColClear, $"smartsign.set.pick {it[0]} {it[1]}", "0 0", "1 1", null, 0, "", ""); } } /// 我的相册(照 gen-smartsign-fs-albums.mjs):相册卡网格(封面 letterbox + 公开/私密 chip + 名/张数)。创建走网页,无新建钮。 private void BuildFsAlbums(CuiElementContainer c, BasePlayer player) { var albums = lastAlbums.TryGetValue(player.userID, out var a) ? a : null; int total = albums?.Count ?? 0; FsHeader(c, "我的相册", 0.2f, $"{total} 个相册 · 公开相册里的图会出现在广场、游戏内也能被别人看到", 0.13f, 0.72f); if (albums == null || albums.Count == 0) { FsNote(c, "还没有相册~\n去网页 ss.rustxz.com「我的相册」新建,把成品归类、公开到广场"); return; } float cardHpx = 0.369f * ScrH; // (0.80-0.04-0.022)/2 × 720 int n = Math.Min(albums.Count, 30); FsScroll(c, UiRoot + ".gv", UiRoot, $"{F(FsX0)} 0.04", $"{F(FsVpX1)} 0.80", FsGridContentH(n, cardHpx)); for (int i = 0; i < n; i++) { var al = albums[i]; string card = FsGridCard(c, UiRoot + ".gv", UiRoot + ".a", i, cardHpx); string cvUrl = string.IsNullOrEmpty(al.coverCropId) ? null : $"{config.ApiBase}/u/{al.coverCropId}.png"; FsSquareThumb(c, card, cvUrl, 0.675f * cardHpx, 0.045f * cardHpx, al.coverW, al.coverH, "空相册"); bool pub = al.isPublic == 1; Rp(c, card + ".pg", card, pub ? FsAcc : FsCard2, "0.60 0.865", "0.855 0.93", 0.255f * FsCardWpx, 0.065f * cardHpx, 5); Txt(c, card + ".pgt", card + ".pg", pub ? "公开" : "私密", FzTiny, TextAnchor.MiddleCenter, pub ? FsInk : FsSub, FontBold, "0 0", "1 1"); Txt(c, card + ".n", card, Trunc(al.name, 10), FzBody, TextAnchor.LowerLeft, FsInk, FontBold, "0.06 0.135", "0.94 0.24"); Txt(c, card + ".ct", card, $"{al.count} 张", FzSmall, TextAnchor.LowerLeft, FsSub, FontReg, "0.06 0.05", "0.94 0.125"); Btn(c, card + ".go", card, ColClear, $"smartsign.album.open {al.id}", "0 0", "1 1", null, 0, "", ""); } } /// 广场(照 gen-smartsign-fs-plaza.mjs):公开相册卡网格(封面 + 名 + by作者 + 热度橙字)。点卡=册内图。 private void BuildFsPlaza(CuiElementContainer c, BasePlayer player) { var albums = lastPlaza.TryGetValue(player.userID, out var a) ? a : null; FsHeader(c, "广场", 0.14f, "大家的公开相册 · 点进去可把喜欢的图贴到自己的牌子", 0.09f, 0.6f); // 排序徽标(数据即最热序,纯展示) Rp(c, UiRoot + ".so", UiRoot, FsCard, "0.80 0.858", "0.895 0.905", 0.095f * ScrW, 0.047f * ScrH, 6); Txt(c, UiRoot + ".so.t", UiRoot + ".so", "最热 ▾", FzBody, TextAnchor.MiddleCenter, FsSub, FontReg, "0 0", "1 1"); if (albums == null || albums.Count == 0) { FsNote(c, "广场还没有公开相册~\n把你的相册设为公开,作品就会出现在这里"); return; } float cardHpx = 0.369f * ScrH; int n = Math.Min(albums.Count, 30); FsScroll(c, UiRoot + ".gv", UiRoot, $"{F(FsX0)} 0.04", $"{F(FsVpX1)} 0.80", FsGridContentH(n, cardHpx)); for (int i = 0; i < n; i++) { var al = albums[i]; string card = FsGridCard(c, UiRoot + ".gv", UiRoot + ".p", i, cardHpx); FsSquareThumb(c, card, al.coverUrl, 0.655f * cardHpx, 0.045f * cardHpx, al.coverW, al.coverH, "空相册"); Txt(c, card + ".n", card, Trunc(al.name, 10), FzBody, TextAnchor.LowerLeft, FsInk, FontBold, "0.06 0.19", "0.94 0.29"); Txt(c, card + ".o", card, "by " + Trunc(al.owner ?? "玩家", 8), FzSmall, TextAnchor.LowerLeft, FsSub, FontReg, "0.06 0.105", "0.6 0.18"); Txt(c, card + ".h", card, $"热度 {al.heat}", FzSmall, TextAnchor.LowerRight, FsAcc, FontReg, "0.5 0.105", "0.94 0.18"); Btn(c, card + ".go", card, ColClear, $"smartsign.plaza.open {al.id}", "0 0", "1 1", null, 0, "", ""); } } private static string FsNotifIcon(NotifItem it) { var t = (it.type ?? "") + "|" + (it.text ?? ""); if (t.Contains("like") || t.Contains("赞")) return "♥"; if (t.Contains("comment") || t.Contains("评论")) return "💬"; if (t.Contains("mention") || t.Contains("@")) return "@"; if (t.Contains("report") || t.Contains("举报")) return "⚑"; return "✓"; } /// 通知中心(照 gen-smartsign-fs-notif.mjs):滚动行列表(图标chip + 未读橙点 + 文案 + 查看),全部已读。 private void BuildFsNotif(CuiElementContainer c, BasePlayer player) { var resp = lastNotif.TryGetValue(player.userID, out var nr) ? nr : null; var list = resp?.notifications; int unread = resp?.unread ?? 0; FsHeader(c, "通知中心", 0.2f, $"{unread} 条未读 · 有人赞你 / 评论你 / @你 / 举报处理结果会出现在这里", 0.13f, 0.7f); if (unread > 0) FsBtn(c, UiRoot + ".ra", UiRoot, FsAccSoft, "smartsign.notif.readall", 0.86f, 0.856f, 0.972f, 0.905f, "全部已读", FzBody, FsAcc, FontBold); if (list == null || list.Count == 0) { FsNote(c, "还没有通知~\n有人赞你、评论你、@你时会出现在这里"); return; } const float rowHpx = 0.088f * ScrH, rowGapPx = 0.014f * ScrH; int n = Math.Min(list.Count, 30); float contentH = n * (rowHpx + rowGapPx) - rowGapPx; string sv = UiRoot + ".nv"; FsScroll(c, sv, UiRoot, $"{F(FsX0)} 0.04", $"{F(FsVpX1)} 0.79", contentH); float rowW = (0.975f - FsX0) / FsVpW; // 行右缘 0.975(照 fixture),留右滚动条带 float rowWpx = 0.947f * ScrW; for (int i = 0; i < n; i++) { var it = list[i]; float yTop = i * (rowHpx + rowGapPx); string row = $"{sv}.n{i}"; RpOff(c, row, sv, FsCard, "0 1", $"{F(rowW)} 1", $"0 {F(-(yTop + rowHpx))}", $"0 {F(-yTop)}", rowWpx, rowHpx, 8); Rp(c, row + ".ic", row, it.read ? FsThumb : FsAccSoft, "0.008 0.2", "0.032 0.8", 0.024f * rowWpx, 0.6f * rowHpx, 6); Txt(c, row + ".ict", row + ".ic", FsNotifIcon(it), FzLabel, TextAnchor.MiddleCenter, it.read ? FsSub : FsAcc, FontBold, "0 0", "1 1"); if (!it.read) Rp(c, row + ".d", row, FsAcc, "0.001 0.42", "0.006 0.58", 0.005f * rowWpx, 0.16f * rowHpx, 2); Txt(c, row + ".t", row, Trunc(it.text ?? "", 46), FzBody, TextAnchor.MiddleLeft, it.read ? FsSub : FsInk, FontReg, "0.045 0", "0.88 1"); var kind = it.nav?.kind ?? ""; var target = it.nav?.id ?? ""; Btn(c, row + ".b", row, ColClear, $"smartsign.notif.open {it.id} {kind} {target}", "0 0", "1 1", null, 0, "", ""); Rp(c, row + ".v.bg", row, FsCard2, "0.9 0.24", "0.985 0.76", 0.085f * rowWpx, 0.52f * rowHpx, 6); Btn(c, row + ".v", row, ColClear, $"smartsign.notif.open {it.id} {kind} {target}", "0.9 0.24", "0.985 0.76", "查看", FzSmall, FsSub, FontReg); } } /// 我的(照 gen-smartsign-fs-me.mjs):居中账户卡(头像/名/SteamID/邮箱三态/信誉/配额条 + 上传扩容走网页)。 private void BuildFsMe(CuiElementContainer c, BasePlayer player, UserResp user) { FsHeader(c, "我的", 0.14f, null, 0f, 0f); const float cardWpx = 0.36f * ScrW, cardHpx = 0.56f * ScrH; // 460.8 × 403.2 string card = UiRoot + ".mecard"; Rp(c, card, UiRoot, FsCard, "0.32 0.24", "0.68 0.80", cardWpx, cardHpx, 14); // 头像(#1 方形:点锚+对称px offset 恒方避免扁;首字回退 + 叠真实 Steam 头像) string nm = !string.IsNullOrEmpty(user?.name) ? user.name : (!string.IsNullOrEmpty(player?.displayName) ? player.displayName : "玩家"); float avSz = 0.24f * cardHpx; // 正方边长(参考px) string ava = card + ".ava"; RpOff(c, ava, card, FsThumb, "0.5 0.83", "0.5 0.83", $"{F(-avSz / 2)} {F(-avSz / 2)}", $"{F(avSz / 2)} {F(avSz / 2)}", avSz, avSz, 16); Txt(c, ava + ".t", ava, nm.Length > 0 ? nm.Substring(0, 1) : "玩", FzTitle, TextAnchor.MiddleCenter, FsSub, FontBold, "0 0", "1 1"); RawImg(c, ava + ".im", ava, $"{config.ApiBase}/api/avatar/{player.userID}", "0 0", "1 1"); Txt(c, card + ".name", card, Trunc(nm, 12), FzTitle, TextAnchor.MiddleCenter, FsInk, FontBold, "0 0.62", "1 0.7"); // SteamID(#1 字号 +4 + 真输入框:点进去可全选复制)。结构照 F5 可用输入框:input 作 bg 子级 + Command + NeedsKeyboard,否则不可聚焦=假框 Txt(c, card + ".sidl", card, "SteamID", FzSmall + 2, TextAnchor.MiddleRight, FsSub, FontReg, "0.11 0.55", "0.38 0.605"); Rp(c, card + ".sidbg", card, FsThumb, "0.4 0.548", "0.88 0.607", 0.48f * cardWpx, 0.059f * cardHpx, 5); c.Add(new CuiElement { Name = card + ".sid", Parent = card + ".sidbg", Components = { new CuiInputFieldComponent { Text = player.userID.ToString(), CharsLimit = 32, FontSize = FzSmall + 4, Font = FontReg, Color = FsInk, Align = TextAnchor.MiddleCenter, NeedsKeyboard = true, Command = "smartsign.noop" }, new CuiRectTransformComponent { AnchorMin = "0.03 0", AnchorMax = "0.97 1" }, }, }); Panel(c, card + ".ln1", card, FsLine, "0.08 0.52", "0.92 0.522"); // 邮箱行(#2 地址独占整行显示完全;状态/按钮下沉子行)——未绑/待验/已绑三态 Txt(c, card + ".el", card, "邮箱", FzBody, TextAnchor.MiddleLeft, FsSub, FontReg, "0.08 0.44", "0.22 0.49"); var em = lastEmail.TryGetValue(player.userID, out var e) ? e : null; if (em != null && em.verified && !string.IsNullOrEmpty(em.email)) { Txt(c, card + ".ev", card, Trunc(em.email, 34), FzBody, TextAnchor.MiddleLeft, FsInk, FontReg, "0.22 0.44", "0.92 0.49"); Txt(c, card + ".es", card, "已绑定", FzSmall, TextAnchor.MiddleLeft, FsOk, FontReg, "0.22 0.385", "0.5 0.43"); Rp(c, card + ".eu.bg", card, FsCard2, "0.74 0.385", "0.92 0.435", 0.18f * cardWpx, 0.05f * cardHpx, 6); Btn(c, card + ".eu", card, ColClear, "smartsign.email.unbind", "0.74 0.385", "0.92 0.435", "解绑", FzSmall, FsSub, FontReg); } else if (em != null && !string.IsNullOrEmpty(em.pendingEmail)) { Txt(c, card + ".ev", card, Trunc(em.pendingEmail, 34), FzBody, TextAnchor.MiddleLeft, FsInk, FontReg, "0.22 0.44", "0.92 0.49"); Txt(c, card + ".es", card, "待验证", FzSmall, TextAnchor.MiddleLeft, FsAcc, FontReg, "0.22 0.385", "0.4 0.43"); Rp(c, card + ".ec.bg", card, FsAccSoft, "0.53 0.385", "0.71 0.435", 0.18f * cardWpx, 0.05f * cardHpx, 6); Btn(c, card + ".ec", card, ColClear, "smartsign.email.code", "0.53 0.385", "0.71 0.435", "输验证码", FzSmall, FsAcc, FontBold); Rp(c, card + ".er.bg", card, FsCard2, "0.73 0.385", "0.92 0.435", 0.19f * cardWpx, 0.05f * cardHpx, 6); Btn(c, card + ".er", card, ColClear, "smartsign.email.resend", "0.73 0.385", "0.92 0.435", "重发", FzSmall, FsSub, FontReg); } else { Txt(c, card + ".ev", card, "未绑定 · 绑定后可收下架/信誉邮件", FzBody, TextAnchor.MiddleLeft, FsSub, FontReg, "0.22 0.44", "0.92 0.49"); Rp(c, card + ".eb.bg", card, FsAccSoft, "0.72 0.385", "0.92 0.435", 0.2f * cardWpx, 0.05f * cardHpx, 6); Btn(c, card + ".eb", card, ColClear, "smartsign.email.bind", "0.72 0.385", "0.92 0.435", "绑定邮箱", FzSmall, FsAcc, FontBold); } // 信誉行 Txt(c, card + ".rl", card, "信誉", FzBody, TextAnchor.MiddleLeft, FsSub, FontReg, "0.08 0.3", "0.25 0.37"); int rep = user?.reputation ?? 0; string repCol = rep >= 80 ? FsOk : rep >= 40 ? FsAcc : ColBad; Txt(c, card + ".rv", card, $"{rep} · {(user?.banned ?? false ? "已封禁" : "未封禁")}", FzBody, TextAnchor.MiddleLeft, repCol, FontReg, "0.25 0.3", "0.47 0.37"); if (!(user?.banned ?? false) && rep < 100) // 未封禁且未满分:提示每月自动回复 Txt(c, card + ".rh", card, "· 每月自动+10", FzSmall, TextAnchor.MiddleLeft, FsDim, FontReg, "0.47 0.3", "0.92 0.37"); // 配额行 + 进度条 Txt(c, card + ".ql", card, "配额", FzBody, TextAnchor.MiddleLeft, FsSub, FontReg, "0.08 0.2", "0.25 0.27"); double usedMb = user?.quota != null ? user.quota.used / 1048576.0 : 0; double quotaMb = user?.quota != null ? user.quota.quota / 1048576.0 : 0; Txt(c, card + ".qv", card, $"{usedMb:0.0} / {quotaMb:0} MB", FzBody, TextAnchor.MiddleLeft, FsInk, FontReg, "0.25 0.2", "0.7 0.27"); Rp(c, card + ".qbar", card, FsThumb, "0.08 0.13", "0.92 0.16", 0.84f * cardWpx, 0.03f * cardHpx, 3); float ratio = quotaMb > 0 ? Mathf.Clamp01((float)(usedMb / quotaMb)) : 0f; if (ratio > 0.005f) Rp(c, card + ".qbarf", card, FsAcc, "0.08 0.13", $"{F(0.08f + 0.84f * ratio)} 0.16", 0.84f * cardWpx * ratio, 0.03f * cardHpx, 3); Txt(c, card + ".note", card, "上传 / 裁图 / 扩容请到网页 ss.rustxz.com", FzSmall, TextAnchor.MiddleCenter, FsDim, FontReg, "0.08 0.03", "0.92 0.09"); // #3 个人中心关闭钮(→ 回素材库):卡片右上角 Rp(c, card + ".x.bg", card, FsCard2, "0.885 0.9", "0.965 0.965", 0.08f * cardWpx, 0.065f * cardHpx, 7); Btn(c, card + ".x", card, ColClear, "smartsign.tab mine", "0.885 0.9", "0.965 0.965", "✕", 16, FsInk, FontBold); } // —— 各页内容区(嵌主壳内容区,复用顶栏/导航/底栏)—— /// 相册详情屏(v0.5.0 全屏):外壳(albums 高亮) + 工具条(返回/改名/公开切换/加图/删除) + 册内图网格(点卡=详情;设封面/移除 chip)。 private void ShowAlbumDetail(BasePlayer player, UserResp user, AlbumDetailResp detail) { CuiHelper.DestroyUi(player, UiRoot); var c = new CuiElementContainer(); BuildFsShell(c, player, user, "albums"); var al = detail.album; string id = al.id; SetGridCtx(player, "album:" + id); // 工具条(y 0.862-0.908) FsBtn(c, UiRoot + ".back", UiRoot, FsCard, "smartsign.tab albums", FsX0, 0.862f, 0.11f, 0.908f, "← 返回", FzBody, FsSub, FontReg); Txt(c, UiRoot + ".dh", UiRoot, Trunc(al.name, 14), FzLabel, TextAnchor.MiddleLeft, FsInk, FontBold, "0.12 0.862", "0.42 0.908"); Txt(c, UiRoot + ".dhs", UiRoot, al.isPublic ? "公开 · 广场可见" : "私密 · 仅自己可见", FzSmall, TextAnchor.MiddleLeft, FsDim, FontReg, "0.43 0.864", "0.59 0.905"); FsBtn(c, UiRoot + ".rn", UiRoot, FsCard, $"smartsign.album.rename {id}", 0.60f, 0.862f, 0.67f, 0.908f, "改名", FzBody, FsSub, FontReg); FsBtn(c, UiRoot + ".pb", UiRoot, FsCard, $"smartsign.album.setpublic {id} {(al.isPublic ? 0 : 1)}", 0.68f, 0.862f, 0.78f, 0.908f, al.isPublic ? "设为私密" : "设为公开", FzBody, FsInk, FontReg); FsBtn(c, UiRoot + ".ad", UiRoot, FsAccSoft, $"smartsign.album.addpick {id}", 0.79f, 0.862f, 0.88f, 0.908f, "+ 加图", FzBody, FsAcc, FontBold); FsBtn(c, UiRoot + ".del", UiRoot, FsCard, $"smartsign.album.delete {id}", 0.89f, 0.862f, 0.972f, 0.908f, "删除", FzBody, FsSub, FontReg); var imgs = detail.images; if (imgs == null || imgs.Count == 0) { FsNote(c, "相册还没有图~\n点右上「+ 加图」从你的成品添加"); CuiHelper.AddUi(player, c); return; } // 按牌型筛选(工具条下方子行 y 0.802-0.842;网格顶下移至 0.79) FsSignFilterBtn(c, player, imgs, FsX0, 0.802f, 0.23f, 0.842f); var shown = FilterBySign(player, imgs); if (shown.Count == 0) { FsNote(c, "该牌型下暂无图~ 点上方「🏷」换一个或选「全部」"); CuiHelper.AddUi(player, c); return; } float cardHpx = 0.362f * ScrH; int n = Math.Min(shown.Count, 30); FsScroll(c, UiRoot + ".gv", UiRoot, $"{F(FsX0)} 0.04", $"{F(FsVpX1)} 0.79", FsGridContentH(n, cardHpx)); for (int i = 0; i < n; i++) { var s = shown[i]; string card = FsStickerCard(c, UiRoot + ".gv", UiRoot + ".ai", i, s, cardHpx, $"smartsign.plaza.img {s.id} album:{id}"); // 设封面/✓封面 + 移除(缩略图上缘两角 chip;加在 .go 之后保证可点) bool isCover = !string.IsNullOrEmpty(al.coverCropId) && al.coverCropId == s.id; if (isCover) { Rp(c, card + ".cvr", card, FsAccSoft, "0.07 0.865", "0.40 0.93", 0.33f * FsCardWpx, 0.065f * cardHpx, 5); Txt(c, card + ".cvr.t", card + ".cvr", "✓ 封面", FzTiny, TextAnchor.MiddleCenter, FsAcc, FontBold, "0 0", "1 1"); } else { Rp(c, card + ".cvr.bg", card, FsCard2, "0.07 0.865", "0.40 0.93", 0.33f * FsCardWpx, 0.065f * cardHpx, 5); Btn(c, card + ".cvr", card, ColClear, $"smartsign.album.setcover {id} {s.id}", "0.07 0.865", "0.40 0.93", "设封面", FzTiny, FsSub, FontBold); } Rp(c, card + ".rm.bg", card, FsCard2, "0.70 0.865", "0.93 0.93", 0.23f * FsCardWpx, 0.065f * cardHpx, 5); Btn(c, card + ".rm", card, ColClear, $"smartsign.album.rmimg {id} {s.id}", "0.70 0.865", "0.93 0.93", "移除", FzTiny, FsSub, FontBold); } CuiHelper.AddUi(player, c); } /// 加图选择器(v0.5.0 全屏,复用素材库网格皮):从你的成品选一张 → 加入相册。 private void ShowAddPicker(BasePlayer player, string albumId, List stickers) { CuiHelper.DestroyUi(player, UiPicker); var c = new CuiElementContainer(); c.Add(new CuiElement { Name = UiPicker, Parent = "Overlay", Components = { new CuiImageComponent { Color = FsBg, Material = "assets/content/ui/uibackgroundblur-ingamemenu.mat" }, new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "1 1" }, new CuiNeedsCursorComponent(), }, }); Txt(c, UiPicker + ".h", UiPicker, "选一张成品加入相册", FzTitle, TextAnchor.MiddleLeft, FsInk, FontBold, $"{F(FsX0)} 0.925", "0.35 0.985"); Txt(c, UiPicker + ".hs", UiPicker, "点一张即加入 · 上传新图请到 ss.rustxz.com", FzBody, TextAnchor.MiddleLeft, FsSub, FontReg, "0.30 0.928", "0.7 0.98"); FsBtn(c, UiPicker + ".x", UiPicker, FsCard, "smartsign.picker.close", 0.9f, 0.928f, 0.972f, 0.978f, "✕ 取消", FzBody, FsSub, FontReg); if (stickers == null || stickers.Count == 0) { Txt(c, UiPicker + ".empty", UiPicker, "你还没有成品~\n先去 ss.rustxz.com 上传图片、裁成牌型", FzLabel, TextAnchor.MiddleCenter, FsDim, FontReg, $"{F(FsX0)} 0.1", $"{F(FsX1)} 0.8"); CuiHelper.AddUi(player, c); return; } float cardHpx = 0.362f * ScrH; int n = Math.Min(stickers.Count, 30); FsScroll(c, UiPicker + ".gv", UiPicker, $"{F(FsX0)} 0.04", $"{F(FsVpX1)} 0.90", FsGridContentH(n, cardHpx)); for (int i = 0; i < n; i++) FsStickerCard(c, UiPicker + ".gv", UiPicker + ".c", i, stickers[i], cardHpx, $"smartsign.album.addimg {albumId} {stickers[i].id}"); CuiHelper.AddUi(player, c); } // ==================== 按牌型筛选(素材库/相册/广场相册共用) ==================== /// 切换网格视图上下文;与上次不同则清掉筛选(导航即重置,避免跨视图串味)。 private void SetGridCtx(BasePlayer p, string ctx) { if (!gridCtx.TryGetValue(p.userID, out var cur) || cur != ctx) signFilter.Remove(p.userID); gridCtx[p.userID] = ctx; } /// 按当前选中牌型(其比例组)过滤成品;无选中或选中已不在列表则原样返回。 private List FilterBySign(BasePlayer p, List src) { if (src == null || !signFilter.TryGetValue(p.userID, out var pf) || string.IsNullOrEmpty(pf)) return src; string g = src.Find(x => x.prefab == pf)?.group; return string.IsNullOrEmpty(g) ? src : src.FindAll(x => x.group == g); } /// 当前视图完整数据集(筛选清单/重绘据此)。 private List CurrentGridData(BasePlayer p) { string ctx = gridCtx.TryGetValue(p.userID, out var g) ? g : "mine"; if (ctx.StartsWith("album:") && lastAlbumDetail.TryGetValue(p.userID, out var ad)) return ad?.images; if (ctx.StartsWith("plaza:") && lastPlazaDetail.TryGetValue(p.userID, out var pd)) return pd?.images; return lastShown.TryGetValue(p.userID, out var sl) ? sl : null; } /// 筛选变更后就地重绘当前视图(用缓存,不重拉)。 private void RerenderGrid(BasePlayer p) { string ctx = gridCtx.TryGetValue(p.userID, out var g) ? g : "mine"; UserResp u = lastUser.TryGetValue(p.userID, out var uu) ? uu : null; if (u != null && ctx.StartsWith("album:") && lastAlbumDetail.TryGetValue(p.userID, out var ad) && ad != null) ShowAlbumDetail(p, u, ad); else if (u != null && ctx.StartsWith("plaza:") && lastPlazaDetail.TryGetValue(p.userID, out var pd) && pd != null) ShowPlazaAlbum(p, u, pd); else FsRedraw(p, "mine"); } /// 工具条「按牌型筛选」入口钮(显示当前牌型名/全部,点开选择清单)。 private void FsSignFilterBtn(CuiElementContainer c, BasePlayer p, List src, float x0, float y0, float x1, float y1) { string sel = signFilter.TryGetValue(p.userID, out var f) && !string.IsNullOrEmpty(f) ? f : null; string label = sel == null ? "全部" : (src?.Find(x => x.prefab == sel)?.label ?? sel); FsBtn(c, UiRoot + ".flt", UiRoot, sel != null ? FsAccSoft : FsCard, "smartsign.sign.pick", x0, y0, x1, y1, $"🏷 {Trunc(label, 8)} ▾", FzBody, sel != null ? FsAcc : FsSub, sel != null ? FontBold : FontReg); } /// 按牌型筛选·选择清单叠层(官网同款:图标+名字;只列当前视图出现过的牌型 + 全部)。 private void ShowSignFilterPicker(BasePlayer player) { CuiHelper.DestroyUi(player, UiSignFilter); var src = CurrentGridData(player); var seen = new HashSet(); var types = new List(); if (src != null) foreach (var s in src) if (!string.IsNullOrEmpty(s.prefab) && seen.Add(s.prefab)) types.Add(s); string cur = signFilter.TryGetValue(player.userID, out var f) ? f : null; var c = new CuiElementContainer(); c.Add(new CuiElement { Name = UiSignFilter, Parent = "Overlay", Components = { new CuiImageComponent { Color = FsBg, Material = "assets/content/ui/uibackgroundblur-ingamemenu.mat" }, new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "1 1" }, new CuiNeedsCursorComponent(), }, }); string card = UiSignFilter + ".card"; float cardWpx = 0.26f * ScrW, cardHpx2 = 0.72f * ScrH; Rp(c, card, UiSignFilter, FsCard, "0.37 0.14", "0.63 0.86", cardWpx, cardHpx2, 14); Txt(c, card + ".ti", card, "按牌型筛选", FzLabel, TextAnchor.MiddleLeft, FsInk, FontBold, "0.07 0.925", "0.7 0.98"); Btn(c, card + ".x", card, ColClear, "smartsign.sign.pickclose", "0.85 0.92", "0.97 0.985", "✕", 16, FsSub, FontBold); int n = types.Count + 1; float rowHpx = 0.055f * ScrH, gapPx = 0.008f * ScrH; float contentH = n * (rowHpx + gapPx); string sv = card + ".sv"; FsScroll(c, sv, card, "0.05 0.05", "0.95 0.9", contentH); float rowAX = 0.9f; // 行宽(留右侧滚动条位,修图3行/滚动条交叠) float rowWpx = rowAX * 0.9f * cardWpx; for (int i = 0; i < n; i++) { bool isAll = i == 0; var s = isAll ? null : types[i - 1]; string pf = isAll ? "all" : s.prefab; bool selRow = isAll ? string.IsNullOrEmpty(cur) : cur == pf; float yTop = i * (rowHpx + gapPx); string row = sv + ".r" + i; RpOff(c, row, sv, selRow ? FsAccSoft : FsCard2, "0 1", $"{F(rowAX)} 1", $"0 {F(-(yTop + rowHpx))}", $"0 {F(-yTop)}", rowWpx, rowHpx, 7); float tx0 = 0.06f; if (!isAll) { float ih = 0.7f * rowHpx; string ab = row + ".i"; RpOff(c, ab, row, FsThumb, "0 0.5", "0 0.5", $"{F(0.04f * rowWpx)} {F(-ih / 2)}", $"{F(0.04f * rowWpx + ih)} {F(ih / 2)}", ih, ih, 5); RawImg(c, ab + ".im", ab, $"{config.ApiBase}/signs/{s.prefab}.png", "0 0", "1 1"); tx0 = (0.04f * rowWpx + ih + 0.03f * rowWpx) / rowWpx; } Txt(c, row + ".t", row, isAll ? "全部" : Trunc(s.label ?? s.prefab, 13), FzBody, TextAnchor.MiddleLeft, selRow ? FsAcc : FsInk, selRow ? FontBold : FontReg, $"{F(tx0)} 0", "0.97 1"); Btn(c, row + ".b", row, ColClear, "smartsign.sign.filter " + pf, "0 0", "1 1", "", FzBody, FsInk, FontReg); } CuiHelper.AddUi(player, c); } [ConsoleCommand("smartsign.sign.pick")] private void CcSignPick(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p != null) ShowSignFilterPicker(p); } [ConsoleCommand("smartsign.sign.pickclose")] private void CcSignPickClose(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p != null) CuiHelper.DestroyUi(p, UiSignFilter); } [ConsoleCommand("smartsign.sign.filter")] private void CcSignFilter(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null) return; string v = arg.HasArgs(1) ? arg.GetString(0) : "all"; if (v == "all") signFilter.Remove(p.userID); else signFilter[p.userID] = v; CuiHelper.DestroyUi(p, UiSignFilter); RerenderGrid(p); } // 只读展示型输入框(如个人页 SteamID)的空提交目标:焦点离开/回车会带文本回传,忽略即可。 [ConsoleCommand("smartsign.noop")] private void CcNoop(ConsoleSystem.Arg arg) { } /// 拉相册详情(我的,含 owner 校验由后端兜)→ 复用 lastUser 弹详情屏。 private IEnumerator OpenAlbumDetail(BasePlayer player, string albumId) { AlbumDetailResp detail = null; yield return GetJson($"/api/game/albums?steamid={player.userID}&albumId={albumId}", r => detail = r); if (detail == null || !detail.ok || detail.album == null) { NotifyErr(player, "读取相册失败,可能已删除"); yield break; } lastAlbumDetail[player.userID] = detail; UserResp user = lastUser.TryGetValue(player.userID, out var u) ? u : null; if (user == null) { yield return GetJson($"/api/game/user/{player.userID}", r => user = r); if (user != null && user.ok) lastUser[player.userID] = user; } if (player == null || !player.IsConnected || user == null || !user.ok) yield break; ShowAlbumDetail(player, user, detail); } /// 拉成品(缺则现拉)→ 弹加图选择器。 private IEnumerator OpenAddPicker(BasePlayer player, string albumId) { var stickers = lastShown.TryGetValue(player.userID, out var sl) ? sl : null; if (stickers == null || stickers.Count == 0) { var fetched = new List(); yield return GetJson($"/api/game/stickers?steamid={player.userID}", r => { if (r?.stickers != null) fetched = r.stickers; }); lastShown[player.userID] = fetched; stickers = fetched; } if (player == null || !player.IsConnected) yield break; ShowAddPicker(player, albumId, stickers); } /// 广场相册详情屏(v0.5.0 全屏):外壳(plaza 高亮) + 返回条/by作者·热度 + 册内图网格(点卡=作品详情)。 private void ShowPlazaAlbum(BasePlayer player, UserResp user, AlbumDetailResp detail) { CuiHelper.DestroyUi(player, UiRoot); var c = new CuiElementContainer(); BuildFsShell(c, player, user, "plaza"); var al = detail.album; SetGridCtx(player, "plaza:" + al.id); lastPlazaDetail[player.userID] = detail; // 筛选重绘用 FsBtn(c, UiRoot + ".back", UiRoot, FsCard, "smartsign.tab plaza", FsX0, 0.862f, 0.125f, 0.908f, "← 返回广场", FzBody, FsSub, FontReg); Txt(c, UiRoot + ".dh", UiRoot, Trunc(al.name, 12), FzLabel, TextAnchor.MiddleLeft, FsInk, FontBold, "0.135 0.862", "0.55 0.908"); Txt(c, UiRoot + ".dhm", UiRoot, $"by {Trunc(al.owner ?? "玩家", 10)} · 热度 {al.heat}", FzBody, TextAnchor.MiddleRight, FsSub, FontReg, "0.6 0.862", $"{F(FsX1)} 0.908"); var imgs = detail.images; if (imgs == null || imgs.Count == 0) { FsNote(c, "这个相册还没有图"); CuiHelper.AddUi(player, c); return; } lastPlazaImgs[player.userID] = imgs; // 缓存供大图层(zoom plaza)回查 sticker // 按牌型筛选(工具条下方子行 y 0.802-0.842;网格顶下移至 0.79) FsSignFilterBtn(c, player, imgs, FsX0, 0.802f, 0.23f, 0.842f); var shown = FilterBySign(player, imgs); if (shown.Count == 0) { FsNote(c, "该牌型下暂无图~ 点上方「🏷」换一个或选「全部」"); CuiHelper.AddUi(player, c); return; } float cardHpx = 0.362f * ScrH; int n = Math.Min(shown.Count, 30); FsScroll(c, UiRoot + ".gv", UiRoot, $"{F(FsX0)} 0.04", $"{F(FsVpX1)} 0.79", FsGridContentH(n, cardHpx)); for (int i = 0; i < n; i++) FsStickerCard(c, UiRoot + ".gv", UiRoot + ".pi", i, shown[i], cardHpx, $"smartsign.plaza.img {shown[i].id} plaza:{al.id}"); CuiHelper.AddUi(player, c); } /// 作品详情(v0.5.0 全屏,照 gen-smartsign-fs-detail.mjs):外壳 + 返回条 + 左大图/名/作者/说说/操作 + 右适用牌型/评论区。 /// 叠在来源页之上(UiImg 根);返回=销毁本层回落来源页。纯 cropId,脱离 painted。 private void ShowPlazaImage(BasePlayer player, string cropId, ImageResp img) { CuiHelper.DestroyUi(player, UiImg); lastImg[player.userID] = img; var c = new CuiElementContainer(); string back = imgBack.TryGetValue(player.userID, out var b) ? b : "mine"; string activeTab = back.StartsWith("album:") ? "albums" : back.StartsWith("plaza:") ? "plaza" : back == "notif" ? "notif" : back == "mine" ? "mine" : "ent"; string backLabel = activeTab == "albums" ? "← 返回相册" : activeTab == "plaza" ? "← 返回广场" : activeTab == "notif" ? "← 返回通知" : activeTab == "mine" ? "← 返回素材库" : "← 返回"; var user = lastUser.TryGetValue(player.userID, out var u) ? u : null; BuildFsShell(c, player, user, activeTab, UiImg); // 返回条 FsBtn(c, UiImg + ".back", UiImg, FsCard, "smartsign.pimg.close", FsX0, 0.862f, 0.125f, 0.908f, backLabel, FzBody, FsSub, FontReg); Txt(c, UiImg + ".dh", UiImg, "作品详情", FzLabel, TextAnchor.MiddleLeft, FsInk, FontBold, "0.135 0.862", "0.4 0.908"); // 左栏:大图(点图看大图)+ 名/作者/社交/说说 FsBigImage(c, UiImg, img.crop?.url, img.crop?.w ?? 0, img.crop?.h ?? 0, $"smartsign.zoom {cropId} img"); var ownerName = img.crop?.owner?.name; if (string.IsNullOrEmpty(ownerName)) ownerName = "玩家"; var label = img.crop?.label ?? ""; Txt(c, UiImg + ".n", UiImg, Trunc(img.crop?.name ?? "", 16), FzTitle, TextAnchor.LowerLeft, FsInk, FontBold, "0.028 0.245", "0.52 0.295"); Txt(c, UiImg + ".au", UiImg, string.IsNullOrEmpty(label) ? $"作者 {ownerName}" : $"作者 {ownerName} · {label}", FzBody, TextAnchor.UpperLeft, FsSub, FontReg, "0.028 0.205", "0.52 0.243"); Txt(c, UiImg + ".so", UiImg, $"♥ {img.likes?.total ?? 0} · 💬 {img.commentCount}", FzBody, TextAnchor.UpperLeft, FsDim, FontReg, "0.028 0.17", "0.3 0.205"); if (!string.IsNullOrEmpty(img.crop?.caption)) Txt(c, UiImg + ".cp", UiImg, "“" + Trunc(img.crop.caption, 20) + "”", FzBody, TextAnchor.UpperLeft, FsDim, FontReg, "0.028 0.135", "0.52 0.17"); if (img.isPrivate) { FsBtn(c, UiImg + ".rp", UiImg, FsAccSoft, "smartsign.pimg.report", 0.028f, 0.06f, 0.29f, 0.115f, "举报违规", FzBody, FsAcc, FontBold); Txt(c, UiImg + ".pnote", UiImg, "私密作品仅可举报", FzSmall, TextAnchor.MiddleLeft, FsDim, FontReg, "0.30 0.06", "0.52 0.115"); FsPrivateRightCol(c, UiImg); } else { // 操作:贴牌(主橙) + 赞/评论/举报(照 fixture y 0.06-0.115) FsBtn(c, UiImg + ".paint", UiImg, FsAcc, "smartsign.pimg.paint", 0.028f, 0.06f, 0.29f, 0.115f, "🖼 贴到面前的牌", FzBody, FsInk, FontBold); if (img.can?.like ?? true) FsBtn(c, UiImg + ".like", UiImg, FsCard, "smartsign.pimg.like", 0.30f, 0.06f, 0.37f, 0.115f, "♥ 赞", FzBody, FsInk, FontBold); if (img.can?.comment ?? true) FsBtn(c, UiImg + ".cm", UiImg, FsCard, "smartsign.pimg.comment", 0.38f, 0.06f, 0.45f, 0.115f, "💬 评论", FzBody, FsInk, FontBold); FsBtn(c, UiImg + ".rp", UiImg, FsCard, "smartsign.pimg.report", 0.46f, 0.06f, 0.52f, 0.115f, "举报", FzBody, FsSub, FontBold); FsDetailRightCol(c, UiImg, img, "smartsign.pimg.comment", entityMode: false); } CuiHelper.AddUi(player, c); } /// 拉广场相册详情(公开) → 弹册内图网格。 private IEnumerator OpenPlazaAlbum(BasePlayer player, string albumId) { AlbumDetailResp detail = null; yield return GetJson($"/api/game/album/{albumId}", r => detail = r); if (detail == null || !detail.ok || detail.album == null) { NotifyErr(player, "读取相册失败,可能已取消公开"); yield break; } UserResp user = lastUser.TryGetValue(player.userID, out var u) ? u : null; if (user == null) { yield return GetJson($"/api/game/user/{player.userID}", r => user = r); if (user != null && user.ok) lastUser[player.userID] = user; } if (player == null || !player.IsConnected || user == null || !user.ok) yield break; ShowPlazaAlbum(player, user, detail); } /// 拉单图详情(viewer 视角,含私密/可否赞评) → 弹叠层。 private IEnumerator OpenPlazaImage(BasePlayer player, string cropId) { ImageResp img = null; yield return GetJson($"/api/game/image/{cropId}?viewer={player.userID}", r => img = r); if (img == null || !img.ok || img.crop == null) { NotifyErr(player, "读取作品失败"); yield break; } if (player == null || !player.IsConnected) yield break; imgTarget[player.userID] = cropId; ShowPlazaImage(player, cropId, img); } /// 广场单图 赞/举报:POST social → 重拉刷新叠层。 private IEnumerator PlazaImgSocial(BasePlayer player, string kind) { if (!imgTarget.TryGetValue(player.userID, out var cropId)) yield break; string path = kind == "like" ? "/api/game/social/like" : "/api/game/social/report"; string payload = kind == "like" ? JsonConvert.SerializeObject(new { steamid = player.userID.ToString(), targetType = "img", targetId = cropId }) : JsonConvert.SerializeObject(new { steamid = player.userID.ToString(), cropId, reason = "游戏内举报" }); SimpleResp r = null; yield return PostJson(path, payload, x => r = x); if (r != null && r.ok) NotifyOk(player, kind == "like" ? "赞了!" : "已举报,管理员会处理"); else { NotifyErr(player, r?.error ?? "操作失败"); yield break; } if (player == null || !player.IsConnected) yield break; yield return OpenPlazaImage(player, cropId); // 刷新叠层(赞数更新) } /// 点通知行:标已读 + 直达(img→开单图详情 / album→开广场相册 / 其他→刷列表)。 private IEnumerator NotifOpen(BasePlayer player, string id, string kind, string targetId) { if (!string.IsNullOrEmpty(id)) yield return PostJson("/api/game/notifications", JsonConvert.SerializeObject(new { steamid = player.userID.ToString(), id }), _ => { }); if (player == null || !player.IsConnected) yield break; if (kind == "img" && !string.IsNullOrEmpty(targetId)) { imgBack[player.userID] = "notif"; yield return OpenPlazaImage(player, targetId); } else if (kind == "album" && !string.IsNullOrEmpty(targetId)) yield return OpenPlazaAlbum(player, targetId); else yield return OpenMain(player, "notif"); } private IEnumerator NotifReadAll(BasePlayer player) { yield return PostJson("/api/game/notifications", JsonConvert.SerializeObject(new { steamid = player.userID.ToString() }), _ => { }); if (player == null || !player.IsConnected) yield break; yield return OpenMain(player, "notif"); } // ==================== T5 我的(②③ 信誉规则 + 邮箱) ==================== // —— T5 邮箱绑定协程(chat 两步:输邮箱→发码→输码→verify)—— private IEnumerator EmailSendCode(BasePlayer player, string email) { SimpleResp r = null; yield return PostJson("/api/game/me/email", JsonConvert.SerializeObject(new { steamid = player.userID.ToString(), email }), x => r = x); if (player == null || !player.IsConnected) yield break; // 断线保护(下面用 player.userID) if (r == null || !r.ok) { NotifyErr(player, r?.error ?? "发送失败,请检查邮箱格式"); yield break; } NotifyOk(player, $"验证码已发往 {email},请查收(含垃圾箱)"); if (!config.UseInputField) { pendingText[player.userID] = "emailcode"; Reply(player, "请在聊天框输入 6 位验证码(下一句,60 秒内;输入「.」取消)"); timer.Once(60f, () => { if (pendingText.TryGetValue(player.userID, out var a) && a == "emailcode") pendingText.Remove(player.userID); }); } yield return OpenMain(player, "me"); // 刷新到待验态(CUI 模式点「输入验证码」弹输入框) } private IEnumerator EmailVerify(BasePlayer player, string code) { SimpleResp r = null; yield return PostJson("/api/game/me/email/verify", JsonConvert.SerializeObject(new { steamid = player.userID.ToString(), code }), x => r = x); if (r != null && r.ok) NotifyOk(player, "邮箱已绑定 ✓"); else NotifyErr(player, r?.error ?? "验证失败"); if (player == null || !player.IsConnected) yield break; yield return OpenMain(player, "me"); } private IEnumerator EmailUnbind(BasePlayer player) { SimpleResp r = null; yield return DeleteJson($"/api/game/me/email?steamid={player.userID}", x => r = x); if (r != null && r.ok) NotifyOk(player, "已解绑通知邮箱"); else NotifyErr(player, r?.error ?? "解绑失败"); if (player == null || !player.IsConnected) yield break; yield return OpenMain(player, "me"); } // ======================= CUI 原子助手 ======================= private static string F(float v) => v.ToString("0.####", CultureInfo.InvariantCulture); // 4×2 卡片网格:第 i 张卡的锚点 min/max(内容区内,gy0/gy1 可留头部条)。与 BuildStickerGrid 同算法。 private const int GridCols = 4, GridRows = 2, GridMax = GridCols * GridRows; private static void GridCell(int i, float gx0, float gx1, float gy0, float gy1, out string min, out string max) { const float gx = 0.012f, gy = 0.03f; float cw = (gx1 - gx0 - (GridCols - 1) * gx) / GridCols; float ch = (gy1 - gy0 - (GridRows - 1) * gy) / GridRows; int col = i % GridCols, row = i / GridCols; float xMin = gx0 + col * (cw + gx); float yMax = gy1 - row * (ch + gy); min = $"{F(xMin)} {F(yMax - ch)}"; max = $"{F(xMin + cw)} {F(yMax)}"; } // 原始图(真实 URL;加载失败留父底色) private static void RawImg(CuiElementContainer c, string name, string parent, string url, string min, string max) { c.Add(new CuiElement { Name = name, Parent = parent, Components = { new CuiRawImageComponent { Url = url }, new CuiRectTransformComponent { AnchorMin = min, AnchorMax = max }, }, }); } // ——— F3 正方缩略(铁律 CuiLayout.Square:点锚+对称像素 offset,跨分辨率恒正方)——— // 覆盖屏面板(叠层)参考px:panel 分数 × 全屏 1280×720。 private static float PanelWpx(float x0, float x1) => (x1 - x0) * 1280f; private static float PanelHpx(float y0, float y1) => (y1 - y0) * 720f; // 网格卡参考px(镜像 GridCell:gx=0.012 gy=0.03)。 private static void GridCardPx(float gx0, float gx1, float gy0, float gy1, float pw, float ph, out float cw, out float ch) { const float gx = 0.012f, gy = 0.03f; cw = (gx1 - gx0 - (GridCols - 1) * gx) / GridCols * pw; ch = (gy1 - gy0 - (GridRows - 1) * gy) / GridRows * ph; } /// 在父盒分数子矩形 [x0..x1]×[y0..y1] 内画最大居中正方(点锚+对称 offset)→ 恒正方、永不溢出。 /// cardW/cardH=父盒在参考系(1280×720)的像素宽高;url 非空贴满该正方(加载失败留 bg);否则 ph 非空显占位字。 /// 返回正方元素名(供叠角标/公开标等挂其上)。 private string SqThumbIn(CuiElementContainer c, string name, string parent, string url, float x0, float y0, float x1, float y1, float cardW, float cardH, string bg = null, string ph = null, int imgW = 0, int imgH = 0) { float side = Mathf.Min((x1 - x0) * cardW, (y1 - y0) * cardH); float cx = (x0 + x1) / 2f, cy = (y0 + y1) / 2f, half = side / 2f; string a = F(cx) + " " + F(cy); c.Add(new CuiElement { Name = name, Parent = parent, Components = { new CuiImageComponent { Color = bg ?? ColCard }, new CuiRectTransformComponent { AnchorMin = a, AnchorMax = a, OffsetMin = F(-half) + " " + F(-half), OffsetMax = F(half) + " " + F(half) }, }, }); if (!string.IsNullOrEmpty(url)) { // ⑥b 保比例:非正方图在方框内按 w/h 居中内嵌(letterbox),不再拉伸变形(如 1:4 悬挂条幅被压扁)。w/h 缺省(0)=填满(旧行为)。 string imin = "0 0", imax = "1 1"; if (imgW > 0 && imgH > 0 && imgW != imgH) { if (imgW > imgH) { float m = (1f - (float)imgH / imgW) / 2f; imin = "0 " + F(m); imax = "1 " + F(1f - m); } else { float m = (1f - (float)imgW / imgH) / 2f; imin = F(m) + " 0"; imax = F(1f - m) + " 1"; } } c.Add(new CuiElement { Name = name + ".img", Parent = name, Components = { new CuiRawImageComponent { Url = url }, new CuiRectTransformComponent { AnchorMin = imin, AnchorMax = imax }, }, }); } else if (!string.IsNullOrEmpty(ph)) Txt(c, name + ".ph", name, ph, 12, TextAnchor.MiddleCenter, ColDimTxt, FontReg, "0 0", "1 1"); return name; } // ======================= DW-style 圆角引擎(RoundTex:运行时 SDF 圆角白蒙版 → FileStorage 缓存;移植 DWStyle.cs)======================= private readonly Dictionary _texCache = new Dictionary(); /// 生成 w×h 圆角(radius)白蒙版 PNG 存 FileStorage,返回 crc(缓存复用)。供 Rp/Rbtn 染色。 private string RoundTex(int w, int h, int radius) { w = Mathf.Max(1, w); h = Mathf.Max(1, h); radius = Mathf.Clamp(radius, 0, Mathf.Min(w, h) / 2); string key = w + "x" + h + "r" + radius; if (_texCache.TryGetValue(key, out var cached)) return cached; var tex = new Texture2D(w, h, TextureFormat.ARGB32, false); var px = new Color32[w * h]; // SDF 抗锯齿:白蒙版,alpha=圆角轮廓覆盖率(边缘 1px 线性过渡),供 color 染色 float cl = radius, cr = w - radius, cb = radius, ct = h - radius; for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) { float pxc = x + 0.5f, pyc = y + 0.5f; float d = -1000f; if (radius > 0) { float ox = pxc < cl ? cl : (pxc > cr ? cr : pxc); float oy = pyc < cb ? cb : (pyc > ct ? ct : pyc); float ddx = pxc - ox, ddy = pyc - oy; if (ddx != 0f || ddy != 0f) d = Mathf.Sqrt(ddx * ddx + ddy * ddy) - radius; } byte a = (byte)Mathf.RoundToInt(Mathf.Clamp01(0.5f - d) * 255f); px[y * w + x] = new Color32(255, 255, 255, a); } tex.SetPixels32(px); tex.Apply(); byte[] png = tex.EncodeToPNG(); UnityEngine.Object.DestroyImmediate(tex); uint crc = FileStorage.server.Store(png, FileStorage.Type.png, CommunityEntity.ServerInstance.net.ID); string id = crc.ToString(); _texCache[key] = id; return id; } /// 圆角面板(分数锚点 + 按元素参考px 生成圆角贴图,染 color)。refW/refH=该元素参考系(1280×720)像素宽高。 private void Rp(CuiElementContainer c, string name, string parent, string color, string min, string max, float refW, float refH, int radius) { c.Add(new CuiElement { Name = name, Parent = parent, Components = { new CuiRawImageComponent { Png = RoundTex(Mathf.RoundToInt(refW), Mathf.RoundToInt(refH), radius), Color = color }, new CuiRectTransformComponent { AnchorMin = min, AnchorMax = max }, }, }); } private static void Panel(CuiElementContainer c, string name, string parent, string color, string min, string max) { c.Add(new CuiElement { Name = name, Parent = parent, Components = { new CuiImageComponent { Color = color }, new CuiRectTransformComponent { AnchorMin = min, AnchorMax = max }, }, }); } private static void Txt(CuiElementContainer c, string name, string parent, string text, int size, TextAnchor align, string color, string font, string min, string max) { c.Add(new CuiElement { Name = name, Parent = parent, Components = { new CuiTextComponent { Text = text, FontSize = size, Align = align, Color = color, Font = font }, new CuiRectTransformComponent { AnchorMin = min, AnchorMax = max }, }, }); } /// 透明/实色按钮;text 非空则叠一层居中/自定义对齐文字。 private static void Btn(CuiElementContainer c, string name, string parent, string color, string command, string min, string max, string text, int size, string textColor, string font) { c.Add(new CuiElement { Name = name, Parent = parent, Components = { new CuiButtonComponent { Color = color, Command = command }, new CuiRectTransformComponent { AnchorMin = min, AnchorMax = max }, }, }); if (!string.IsNullOrEmpty(text)) Txt(c, name + ".t", name, text, size, TextAnchor.MiddleCenter, textColor, font, "0 0", "1 1"); } private static string Trunc(string s, int max) { if (string.IsNullOrEmpty(s)) return ""; return s.Length <= max ? s : s.Substring(0, max - 1) + "…"; } // ======================= 统一通知(提示音 + 公屏 + NCP)======================= // 用户自装 NCP v1.4.2(Codefling 免费):Overlay 层气泡,能盖 TC/背包界面(gametip/聊天会被面板遮)。 [PluginReference] private Plugin NCP; /// 统一通知:公屏聊天 + 提示音 + 气泡。气泡通道自适应(用户拍板): /// 装了 NCP → NCP 气泡;没装 → gametip 顶部横幅兜底(4 秒自动收起)。type:0信息/1警告/2错误/3成功。 private void Notify(BasePlayer player, string msg, int type = 0) { if (player == null || !player.IsConnected) return; if (config.NotifyChat) player.ChatMessage($"[智能贴图] {msg}"); if (config.NotifySound && !string.IsNullOrEmpty(config.SoundEffect) && player.net?.connection != null) { var eff = new Effect(config.SoundEffect, player, 0, Vector3.zero, Vector3.forward); EffectNetwork.Send(eff, player.net.connection); // 仅发给该玩家 } // F6:气泡通道(ncp 默认=有 NCP 走气泡、没装降级 gametip;gametip 强制横幅;off 不弹) var ch = string.IsNullOrEmpty(config.BubbleChannel) ? "ncp" : config.BubbleChannel.ToLowerInvariant(); if (ch != "off") { if (ch == "ncp" && NCP != null) { // NCP.SendNotify 是私有方法(无 [HookMethod])→ 靠 Oxide plugin.Call 反射调用 NCP.Call("SendNotify", player, type, msg); } else { // gametip(强制),或 ncp 但没装 NCP → 自适应降级 player.SendConsoleCommand("gametip.showgametip", msg); timer.Once(4f, () => { if (player != null && player.IsConnected) player.SendConsoleCommand("gametip.hidegametip"); }); } } } // Reply 全量升级为统一通知(信息类);成功/错误处按需用 Notify(...,3/2)。 private void Reply(BasePlayer player, string msg) => Notify(player, msg, 0); private void NotifyOk(BasePlayer player, string msg) => Notify(player, msg, 3); private void NotifyErr(BasePlayer player, string msg) => Notify(player, msg, 2); // ======================= 控制台命令(CUI 交互) ======================= /// 清掉主界面之上的全部全屏叠层(详情/E接管/大图/加图选择器/输入层)。切 tab / 关闭都先走这。 private void DestroyOverlays(BasePlayer p) { CuiHelper.DestroyUi(p, UiPicker); CuiHelper.DestroyUi(p, UiImg); CuiHelper.DestroyUi(p, UiEnt); CuiHelper.DestroyUi(p, UiZoom); CuiHelper.DestroyUi(p, UiInput); CuiHelper.DestroyUi(p, UiSignFilter); } [ConsoleCommand("smartsign.close")] private void CcClose(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p != null) { DestroyOverlays(p); CuiHelper.DestroyUi(p, UiRoot); } } [ConsoleCommand("smartsign.tab")] private void CcTab(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null) return; DestroyOverlays(p); string tab = arg.HasArgs(1) ? arg.GetString(0) : "mine"; ServerMgr.Instance.StartCoroutine(OpenMain(p, tab)); } // —— v0.5.0 素材库页头交互(分段/排序/搜索/扩容,纯界面态,改完就地重绘) —— /// 用缓存直接重绘主界面(界面态切换不重拉数据;无缓存则走完整 OpenMain)。 private void FsRedraw(BasePlayer p, string tab) { if (lastUser.TryGetValue(p.userID, out var u) && u != null) ShowMainCui(p, u, tab); else ServerMgr.Instance.StartCoroutine(OpenMain(p, tab)); } [ConsoleCommand("smartsign.lib.seg")] private void CcLibSeg(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null) return; var st = fsLib.TryGetValue(p.userID, out var s) ? s : (fsLib[p.userID] = new FsLibState()); st.Seg = arg.GetInt(0, 0) == 1 ? 1 : 0; FsRedraw(p, "mine"); } [ConsoleCommand("smartsign.lib.sort")] private void CcLibSort(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null) return; var st = fsLib.TryGetValue(p.userID, out var s) ? s : (fsLib[p.userID] = new FsLibState()); st.Sort = st.Sort == 0 ? 1 : 0; FsRedraw(p, "mine"); } [ConsoleCommand("smartsign.lib.search")] private void CcLibSearch(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null) return; var st = fsLib.TryGetValue(p.userID, out var s) ? s : (fsLib[p.userID] = new FsLibState()); PromptText(p, "lib.search", "搜索素材库", "输入名字或牌型关键词;输入 - 清空搜索", 24, 60, st.Query); } [ConsoleCommand("smartsign.lib.clearsearch")] private void CcLibClearSearch(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null) return; if (fsLib.TryGetValue(p.userID, out var st)) st.Query = null; FsRedraw(p, "mine"); } [ConsoleCommand("smartsign.lib.buy")] private void CcLibBuy(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p != null) NotifyOk(p, "扩容请到网页 ss.rustxz.com「素材库 → +扩容」办理"); } [ConsoleCommand("smartsign.sets.kind")] private void CcSetsKind(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null) return; fsSetsKind[p.userID] = Mathf.Clamp(arg.GetInt(0, 0), 0, 2); FsRedraw(p, "sets"); } [ConsoleCommand("smartsign.pick")] private void CcPick(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null) return; string cropId = arg.HasArgs(1) ? arg.GetString(0) : ""; if (string.IsNullOrEmpty(cropId)) return; var s = lastShown.TryGetValue(p.userID, out var list) && list != null ? list.Find(x => x.id == cropId) : null; if (s == null) { Reply(p, "请重新打开 /img 再选图"); return; } ServerMgr.Instance.StartCoroutine(PaintFlow(p, s)); } [ConsoleCommand("smartsign.set.pick")] private void CcSetPick(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null || !arg.HasArgs(2)) return; string kind = arg.GetString(0), id = arg.GetString(1); if (!lastSets.TryGetValue(p.userID, out var sets) || sets == null) { Reply(p, "请重新打开 /img → 我的方案"); return; } if (kind == "anim") { var s = sets.animSets?.Find(x => x.id == id); if (s == null) { Reply(p, "方案不存在,请刷新「我的方案」"); return; } ServerMgr.Instance.StartCoroutine(ApplyAnimSet(p, s)); } else if (kind == "matrix") { var s = sets.matrixSets?.Find(x => x.id == id); if (s == null) { Reply(p, "方案不存在,请刷新「我的方案」"); return; } var crops = string.IsNullOrEmpty(s.sourceCropId) ? null : new List { s.sourceCropId }; ServerMgr.Instance.StartCoroutine(ApplyMatrixWall(p, s.name, s.tilePrefab, s.label ?? s.tilePrefab, s.cols, s.rows, s.brightness, 0f, s.tiles, null, 0, crops, null)); } else if (kind == "animmatrix") { var s = sets.animMatrixSets?.Find(x => x.id == id); if (s == null) { Reply(p, "方案不存在,请刷新「我的方案」"); return; } ServerMgr.Instance.StartCoroutine(ApplyMatrixWall(p, s.name, s.tilePrefab, s.label ?? s.tilePrefab, s.cols, s.rows, s.brightness, s.speed, null, s.tilesByFrame, s.frameCount, s.frameCropIds, s.frameBrightness)); } } // ==================== 游戏内编排命令(P3 F11) ==================== [ConsoleCommand("smartsign.compose.open")] private void CcComposeOpen(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p != null) ServerMgr.Instance.StartCoroutine(OpenCompose(p)); } [ConsoleCommand("smartsign.compose.close")] private void CcComposeClose(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null) return; KillPreview(p); composing.Remove(p.userID); CuiHelper.DestroyUi(p, UiComposePick); CuiHelper.DestroyUi(p, UiCompose); } /// 取草稿;无则忽略(面板已关的残留点击)。 private ComposeState Draft(BasePlayer p) => p != null && composing.TryGetValue(p.userID, out var st) ? st : null; [ConsoleCommand("smartsign.compose.kind")] private void CcComposeKind(ConsoleSystem.Arg arg) { var p = arg?.Player(); var st = Draft(p); if (st == null || !arg.HasArgs(1)) return; if (!string.IsNullOrEmpty(st.EditId)) { Reply(p, "编辑中不可换类型——想换类型请「另存为」后新建"); return; } var k = arg.GetString(0); if (k != "anim" && k != "matrix" && k != "animmatrix") return; st.Kind = k; if (k == "matrix" && st.Frames.Count > 1) st.Frames.RemoveRange(1, st.Frames.Count - 1); // 矩阵仅 1 源图 // 动画矩阵不能用无动画变体的 125x125 if (k == "animmatrix" && st.TilePrefab == "sign.neon.125x125") st.TilePrefab = "sign.neon.xl"; // 普通矩阵单牌用非动画款 if (k == "matrix" && st.TilePrefab.EndsWith(".animated")) st.TilePrefab = st.TilePrefab.Replace(".animated", ""); ShowComposeCui(p); } [ConsoleCommand("smartsign.compose.pickframe")] private void CcComposePickFrame(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (Draft(p) != null) ShowComposePicker(p); } [ConsoleCommand("smartsign.compose.pickclose")] private void CcComposePickClose(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p != null) CuiHelper.DestroyUi(p, UiComposePick); } [ConsoleCommand("smartsign.compose.addframe")] private void CcComposeAddFrame(ConsoleSystem.Arg arg) { var p = arg?.Player(); var st = Draft(p); if (st == null || !arg.HasArgs(1)) return; var stickers = lastShown.TryGetValue(p.userID, out var sl) ? sl : null; var s = stickers?.Find(x => x.id == arg.GetString(0)); if (s == null) return; int cap = st.Kind == "matrix" ? 1 : 5; if (st.Frames.Count >= cap) { Reply(p, st.Kind == "matrix" ? "矩阵只要 1 张源图" : "最多 5 帧"); return; } if (st.Kind != "matrix" && st.Frames.Count > 0 && st.Frames[0].group != s.group) { Reply(p, "各帧须同规格(同比例组)"); return; } st.Frames.Add(s); st.FrameB.Add(st.Brightness); CuiHelper.DestroyUi(p, UiComposePick); ShowComposeCui(p); } [ConsoleCommand("smartsign.compose.rmframe")] private void CcComposeRmFrame(ConsoleSystem.Arg arg) { var p = arg?.Player(); var st = Draft(p); if (st == null || !arg.HasArgs(1)) return; int i = arg.GetInt(0, -1); if (i >= 0 && i < st.Frames.Count) { st.Frames.RemoveAt(i); if (i < st.FrameB.Count) st.FrameB.RemoveAt(i); } ShowComposeCui(p); } [ConsoleCommand("smartsign.compose.move")] private void CcComposeMove(ConsoleSystem.Arg arg) { var p = arg?.Player(); var st = Draft(p); if (st == null || !arg.HasArgs(2)) return; int i = arg.GetInt(0, -1), d = arg.GetInt(1, 0), j = i + d; if (i < 0 || i >= st.Frames.Count || j < 0 || j >= st.Frames.Count) return; var t = st.Frames[i]; st.Frames[i] = st.Frames[j]; st.Frames[j] = t; if (i < st.FrameB.Count && j < st.FrameB.Count) { var tb = st.FrameB[i]; st.FrameB[i] = st.FrameB[j]; st.FrameB[j] = tb; } ShowComposeCui(p); } [ConsoleCommand("smartsign.compose.step")] private void CcComposeStep(ConsoleSystem.Arg arg) { var p = arg?.Player(); var st = Draft(p); if (st == null || !arg.HasArgs(2)) return; int d = arg.GetInt(1, 0); if (arg.GetString(0) == "c") st.Cols = Mathf.Clamp(st.Cols + d, 1, 10); else st.Rows = Mathf.Clamp(st.Rows + d, 1, 10); ShowComposeCui(p); } [ConsoleCommand("smartsign.compose.speed")] private void CcComposeSpeed(ConsoleSystem.Arg arg) { var p = arg?.Player(); var st = Draft(p); if (st == null || !arg.HasArgs(1)) return; st.Speed = Mathf.Clamp(arg.GetFloat(0, 1f), 0.1f, 5f); ShowComposeCui(p); } [ConsoleCommand("smartsign.compose.bright")] private void CcComposeBright(ConsoleSystem.Arg arg) { var p = arg?.Player(); var st = Draft(p); if (st == null || !arg.HasArgs(1)) return; st.Brightness = Mathf.Clamp(st.Brightness + arg.GetInt(0, 0), 1, 10); for (int i = 0; i < st.FrameB.Count; i++) st.FrameB[i] = st.Brightness; // 整体=一键套用全帧 ShowComposeCui(p); } [ConsoleCommand("smartsign.compose.fbright")] private void CcComposeFBright(ConsoleSystem.Arg arg) { var p = arg?.Player(); var st = Draft(p); if (st == null || !arg.HasArgs(1)) return; int i = arg.GetInt(0, -1); if (i < 0 || i >= st.Frames.Count) return; while (st.FrameB.Count <= i) st.FrameB.Add(st.Brightness); st.FrameB[i] = st.FrameB[i] >= 10 ? 1 : st.FrameB[i] + 1; // 点击循环 1→10 ShowComposeCui(p); } // 单牌循环顺序(动画矩阵跳过无动画变体的 125x125;普通矩阵用非动画款) private static readonly string[] TileCycleMatrix = { "sign.neon.xl", "sign.neon.125x215", "sign.neon.125x125" }; private static readonly string[] TileCycleAnim = { "sign.neon.xl", "sign.neon.125x215" }; [ConsoleCommand("smartsign.compose.tile")] private void CcComposeTile(ConsoleSystem.Arg arg) { var p = arg?.Player(); var st = Draft(p); if (st == null) return; var cyc = st.Kind == "animmatrix" ? TileCycleAnim : TileCycleMatrix; int idx = Array.IndexOf(cyc, st.TilePrefab); st.TilePrefab = cyc[(idx + 1 + cyc.Length) % cyc.Length]; ShowComposeCui(p); } [ConsoleCommand("smartsign.compose.album")] private void CcComposeAlbum(ConsoleSystem.Arg arg) { var p = arg?.Player(); var st = Draft(p); if (st == null) return; var albums = lastAlbums.TryGetValue(p.userID, out var al) ? al : null; int count = albums?.Count ?? 0; st.AlbumIdx = st.AlbumIdx + 1 >= count ? -1 : st.AlbumIdx + 1; // -1(不挂) → 0..n-1 → 回 -1 ShowComposeCui(p); } [ConsoleCommand("smartsign.compose.preview")] private void CcComposePreview(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (Draft(p) != null) ServerMgr.Instance.StartCoroutine(ComposePreview(p)); } [ConsoleCommand("smartsign.compose.save")] private void CcComposeSave(ConsoleSystem.Arg arg) { var p = arg?.Player(); var st = Draft(p); if (st != null) ServerMgr.Instance.StartCoroutine(ComposeSave(p, string.IsNullOrEmpty(st.EditId) ? "create" : "update")); } [ConsoleCommand("smartsign.compose.saveas")] private void CcComposeSaveAs(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (Draft(p) != null) ServerMgr.Instance.StartCoroutine(ComposeSave(p, "copy")); } [ConsoleCommand("smartsign.set.edit")] private void CcSetEdit(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null || !arg.HasArgs(2)) return; string kind = arg.GetString(0); if (kind != "anim" && kind != "matrix" && kind != "animmatrix") return; ServerMgr.Instance.StartCoroutine(OpenComposeEdit(p, kind, arg.GetString(1))); } // ==================== T2 相册管理命令 ==================== /// 相册写操作统一走此:POST /albums → 通知 → 刷新(reopen="list"回列表 / =albumId 刷详情)。所有权后端兜。 private IEnumerator AlbumAction(BasePlayer player, object body, string okMsg, string reopen) { SimpleResp r = null; yield return PostJson("/api/game/albums", JsonConvert.SerializeObject(body), x => r = x); if (r != null && r.ok) NotifyOk(player, okMsg); else { NotifyErr(player, r?.error ?? "操作失败"); yield break; } if (player == null || !player.IsConnected) yield break; if (reopen == "list") yield return OpenMain(player, "albums"); else if (!string.IsNullOrEmpty(reopen)) yield return OpenAlbumDetail(player, reopen); } [ConsoleCommand("smartsign.album.open")] private void CcAlbumOpen(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null) return; var id = arg.HasArgs(1) ? arg.GetString(0) : ""; if (!string.IsNullOrEmpty(id)) ServerMgr.Instance.StartCoroutine(OpenAlbumDetail(p, id)); } [ConsoleCommand("smartsign.album.paint")] private void CcAlbumPaint(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null || !arg.HasArgs(1)) return; var cropId = arg.GetString(0); var detail = lastAlbumDetail.TryGetValue(p.userID, out var d) ? d : null; var s = detail?.images?.Find(x => x.id == cropId); if (s == null) { Reply(p, "请重新打开相册再试"); return; } ServerMgr.Instance.StartCoroutine(PaintFlow(p, s)); // 自己相册的图=自己的成品,直接贴 } [ConsoleCommand("smartsign.album.create")] private void CcAlbumCreate(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null) return; PromptText(p, "album.create", "新建相册", "给相册起个名字(最多 20 字)", 20, 30); } [ConsoleCommand("smartsign.album.rename")] private void CcAlbumRename(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null) return; var id = arg.HasArgs(1) ? arg.GetString(0) : ""; if (string.IsNullOrEmpty(id)) return; string old = lastAlbumDetail.TryGetValue(p.userID, out var d) && d?.album != null && d.album.id == id ? d.album.name : null; PromptText(p, "album.rename:" + id, "相册改名", "输入新的相册名(最多 20 字)", 20, 30, old); } [ConsoleCommand("smartsign.album.setpublic")] private void CcAlbumSetPublic(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null || !arg.HasArgs(2)) return; var id = arg.GetString(0); bool pub = arg.GetString(1) == "1"; var body = new { steamid = p.userID.ToString(), action = "setPublic", albumId = id, @public = pub }; ServerMgr.Instance.StartCoroutine(AlbumAction(p, body, pub ? "已设为公开(广场可见)" : "已设为私密", id)); } [ConsoleCommand("smartsign.album.setcover")] private void CcAlbumSetCover(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null || !arg.HasArgs(2)) return; var id = arg.GetString(0); var cropId = arg.GetString(1); var body = new { steamid = p.userID.ToString(), action = "setCover", albumId = id, cropId }; ServerMgr.Instance.StartCoroutine(AlbumAction(p, body, "已设为封面", id)); } [ConsoleCommand("smartsign.album.delete")] private void CcAlbumDelete(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null || !arg.HasArgs(1)) return; var id = arg.GetString(0); var body = new { steamid = p.userID.ToString(), action = "delete", albumId = id }; ServerMgr.Instance.StartCoroutine(AlbumAction(p, body, "已删除相册", "list")); } [ConsoleCommand("smartsign.album.rmimg")] private void CcAlbumRmImg(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null || !arg.HasArgs(2)) return; var id = arg.GetString(0); var cropId = arg.GetString(1); var body = new { steamid = p.userID.ToString(), action = "removeImage", albumId = id, cropId }; ServerMgr.Instance.StartCoroutine(AlbumAction(p, body, "已从相册移除", id)); } [ConsoleCommand("smartsign.album.addpick")] private void CcAlbumAddPick(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null || !arg.HasArgs(1)) return; ServerMgr.Instance.StartCoroutine(OpenAddPicker(p, arg.GetString(0))); } [ConsoleCommand("smartsign.album.addimg")] private void CcAlbumAddImg(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null || !arg.HasArgs(2)) return; var id = arg.GetString(0); var cropId = arg.GetString(1); var body = new { steamid = p.userID.ToString(), action = "addImages", albumId = id, cropId }; CuiHelper.DestroyUi(p, UiPicker); // 关选择器再刷详情 ServerMgr.Instance.StartCoroutine(AlbumAction(p, body, "已加入相册", id)); } [ConsoleCommand("smartsign.picker.close")] private void CcPickerClose(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p != null) CuiHelper.DestroyUi(p, UiPicker); } // ==================== T3 广场深浏览命令 ==================== [ConsoleCommand("smartsign.plaza.open")] private void CcPlazaOpen(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null || !arg.HasArgs(1)) return; ServerMgr.Instance.StartCoroutine(OpenPlazaAlbum(p, arg.GetString(0))); } [ConsoleCommand("smartsign.plaza.img")] private void CcPlazaImg(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null || !arg.HasArgs(1)) return; imgBack[p.userID] = arg.HasArgs(2) ? arg.GetString(1) : "mine"; // 详情返回上下文(mine|album:|plaza:|notif) ServerMgr.Instance.StartCoroutine(OpenPlazaImage(p, arg.GetString(0))); } [ConsoleCommand("smartsign.pimg.close")] private void CcPImgClose(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p != null) CuiHelper.DestroyUi(p, UiImg); } [ConsoleCommand("smartsign.pimg.like")] private void CcPImgLike(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p != null) ServerMgr.Instance.StartCoroutine(PlazaImgSocial(p, "like")); } [ConsoleCommand("smartsign.pimg.paint")] private void CcPImgPaint(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null || !imgTarget.TryGetValue(p.userID, out var cropId)) return; var im = lastImg.TryGetValue(p.userID, out var ir) ? ir : null; DestroyOverlays(p); // 关掉详情等叠层露出世界/主界面;成功贴上后 PaintFlow 再关主界面 // 自己的成品(素材库/相册/通知里都是本人图):直接贴。后端 /collect 拒收自己的图("这是你自己的成品…"), // 走收藏必失败(用户误报"收藏失败")→ 按 isOwner 分流,仅他人公开图才收藏后贴。 if (im != null && im.isOwner && im.crop != null && im.crop.id == cropId) { var s = new Sticker { id = im.crop.id, name = im.crop.name, caption = im.crop.caption, label = im.crop.label, group = im.crop.group, groupRatio = im.crop.groupRatio, url = im.crop.url, w = im.crop.w, h = im.crop.h, likes = im.likes?.total ?? 0, comments = im.commentCount, }; ServerMgr.Instance.StartCoroutine(PaintFlow(p, s)); return; } ServerMgr.Instance.StartCoroutine(CollectAndPaint(p, cropId)); } /// 广场/他人图贴牌(F2):先对准校验 → POST /collect 复制为本人成品(去重不重复扣配额)→ 拿新 sticker 继续 PaintFlow。 private IEnumerator CollectAndPaint(BasePlayer player, string cropId) { // 先确认对准了可贴的牌,避免收藏了却没牌可贴(PaintFlow 内还会再校验一次) if (!GetLookedSign(player, out _)) { Reply(player, $"请先对准要贴的 木牌/画框/霓虹(≤{config.MaxDistance:0}米)再点贴牌"); yield break; } CollectResp r = null; yield return PostJson("/api/game/collect", JsonConvert.SerializeObject(new { steamid = player.userID.ToString(), cropId }), x => r = x); if (r == null || !r.ok || r.sticker == null) { NotifyErr(player, r?.error ?? "收藏失败(可能是私密作品或配额不足)"); yield break; } if (player == null || !player.IsConnected) yield break; CuiHelper.DestroyUi(player, UiImg); yield return PaintFlow(player, r.sticker, collected: true); } [ConsoleCommand("smartsign.pimg.report")] private void CcPImgReport(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p != null && imgTarget.TryGetValue(p.userID, out var cropId)) ServerMgr.Instance.StartCoroutine(StartReportInput(p, cropId)); } /// 举报前置流(用户反馈):①须已绑定邮箱(服务器端 reportCore 强校验,这里提前拦提示更友好) /// ②聊天框输入理由才提交(服务器端另有 10 次/日限额)。 private IEnumerator StartReportInput(BasePlayer player, string cropId) { var em = lastEmail.TryGetValue(player.userID, out var e0) ? e0 : null; if (em == null || !em.verified || string.IsNullOrEmpty(em.email)) { yield return GetJson($"/api/game/me/email?steamid={player.userID}", r => { if (r != null && r.ok) { em = r; lastEmail[player.userID] = r; } }); } if (player == null || !player.IsConnected) yield break; if (em == null || !em.verified || string.IsNullOrEmpty(em.email)) { NotifyErr(player, "举报需先绑定邮箱(用于接收处理结果):/img → 我的 → 绑定邮箱"); yield break; } CuiHelper.DestroyUi(player, UiEnt); CuiHelper.DestroyUi(player, UiImg); PromptText(player, "report:" + cropId, "举报违规作品", "简述举报理由(管理员会据此核实;每日限 10 次)", 140, 60); } private IEnumerator SubmitReport(BasePlayer player, string cropId, string reason) { SimpleResp r = null; yield return PostJson("/api/game/social/report", JsonConvert.SerializeObject(new { steamid = player.userID.ToString(), cropId, reason }), x => r = x); if (r != null && r.ok) NotifyOk(player, "已提交举报,管理员会尽快处理;结果将邮件通知你"); else NotifyErr(player, r?.error ?? "举报失败,请稍后再试"); } [ConsoleCommand("smartsign.pimg.comment")] private void CcPImgComment(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null || !imgTarget.TryGetValue(p.userID, out var cropId)) return; // v0.5.0:输入层叠在全屏详情之上,不销底层页(取消=留在详情) PromptText(p, "comment:" + cropId, "写条评论", "输入评论内容(最多 140 字)", 140, 30); } // ==================== T4 通知中心命令 ==================== [ConsoleCommand("smartsign.notif.open")] private void CcNotifOpen(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null || !arg.HasArgs(1)) return; string id = arg.GetString(0); string kind = arg.HasArgs(2) ? arg.GetString(1) : ""; string target = arg.HasArgs(3) ? arg.GetString(2) : ""; ServerMgr.Instance.StartCoroutine(NotifOpen(p, id, kind, target)); } [ConsoleCommand("smartsign.notif.readall")] private void CcNotifReadAll(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p != null) ServerMgr.Instance.StartCoroutine(NotifReadAll(p)); } // ==================== T5 邮箱命令 ==================== [ConsoleCommand("smartsign.email.bind")] private void CcEmailBind(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null) return; PromptText(p, "email", "绑定通知邮箱", "输入你的邮箱地址(用于接收举报/下架/信誉通知)", 60, 60); } [ConsoleCommand("smartsign.email.code")] private void CcEmailCode(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null) return; PromptText(p, "emailcode", "输入验证码", "输入邮件里的 6 位验证码", 6, 60); } [ConsoleCommand("smartsign.email.resend")] private void CcEmailResend(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null) return; if (lastEmail.TryGetValue(p.userID, out var e) && e != null && !string.IsNullOrEmpty(e.pendingEmail)) ServerMgr.Instance.StartCoroutine(EmailSendCode(p, e.pendingEmail)); else NotifyErr(p, "没有待验证的邮箱,请先点「绑定邮箱」"); } [ConsoleCommand("smartsign.email.unbind")] private void CcEmailUnbind(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p != null) ServerMgr.Instance.StartCoroutine(EmailUnbind(p)); } // ======================= 智能实体 CUI 交互(S4.2) ======================= [ConsoleCommand("smartsign.ent.close")] private void CcEntClose(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p != null) CuiHelper.DestroyUi(p, UiEnt); } /// 取当前实体 CUI 目标的 (entityId, PaintedInfo)。 private bool EntTarget(BasePlayer p, out string eid, out PaintedInfo info) { info = null; eid = null; return p != null && entityCuiTarget.TryGetValue(p.userID, out eid) && painted.TryGetValue(eid, out info); } private Signage FindSign(string eid) { if (!ulong.TryParse(eid, out var id)) return null; return BaseNetworkable.serverEntities.Find(new NetworkableId(id)) as Signage; } [ConsoleCommand("smartsign.ent.clear")] private void CcEntClear(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (!EntTarget(p, out var eid, out _)) return; var sign = FindSign(eid); if (sign == null) { NotifyErr(p, "牌子已不在了"); CuiHelper.DestroyUi(p, UiEnt); painted.Remove(eid); SavePainted(); return; } if (!p.IsAdmin && !PlayerCanEdit(sign, p)) { NotifyErr(p, "你没有编辑权限"); return; } try { ClearSignCompletely(sign); SuppressNativeEdit(sign, false); } // 全帧清 + 解锁(清后可再原生编辑) catch (Exception ex) { if (config.Debug) PrintWarning($"清图失败: {ex.Message}"); } if (painted.TryGetValue(eid, out var pinfo) && !string.IsNullOrEmpty(pinfo.Wall)) StopWallAnim(pinfo.Wall); painted.Remove(eid); SavePainted(); NotifyOk(p, "已清除该牌的图"); CuiHelper.DestroyUi(p, UiEnt); } [ConsoleCommand("smartsign.ent.like")] private void CcEntLike(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (EntTarget(p, out _, out var info)) ServerMgr.Instance.StartCoroutine(EntSocial(p, info.CropId, "like")); } [ConsoleCommand("smartsign.ent.repick")] private void CcEntRepick(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null) return; CuiHelper.DestroyUi(p, UiEnt); ServerMgr.Instance.StartCoroutine(OpenMain(p, "mine")); // 回主界面,点图即贴(准星保持对牌) } [ConsoleCommand("smartsign.ent.report")] private void CcEntReport(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (EntTarget(p, out _, out var info)) ServerMgr.Instance.StartCoroutine(StartReportInput(p, info.CropId)); } // —— v0.5.0 E接管·灯牌调整(亮度=按 ?b= 重下帧烘焙重写;速度=animationSpeed/墙同步驱动重启) —— [ConsoleCommand("smartsign.ent.bright")] private void CcEntBright(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (!EntTarget(p, out var eid, out var info)) return; var neon = FindSign(eid) as NeonSign; if (neon == null) return; if (!p.IsAdmin && !PlayerCanEdit(neon, p)) { NotifyErr(p, "你没有编辑权限"); return; } if (!string.IsNullOrEmpty(info.Wall)) { NotifyErr(p, "矩阵墙亮度请在网页「霓虹工作台」里调,重新套用生效"); return; } int cur = info.B > 0 ? info.B : 6; int nb = Mathf.Clamp(cur + (arg.GetInt(0, 0) >= 0 ? 1 : -1), 1, 10); if (nb == cur) { Notify(p, cur >= 10 ? "已是最高亮度" : "已是最低亮度"); return; } ServerMgr.Instance.StartCoroutine(EntRebright(p, eid, info, neon, nb)); } /// 按新亮度重下全部帧(?w&h&b 平台烘焙)→ 重写帧槽 → 保持现速度重上电 → 刷新 E CUI。 private IEnumerator EntRebright(BasePlayer player, string eid, PaintedInfo info, NeonSign neon, int nb) { var crops = info.AllCrops != null && info.AllCrops.Count > 0 ? info.AllCrops : new List { info.CropId }; int slots = neon.textureIDs != null && neon.textureIDs.Length > 0 ? neon.textureIDs.Length : 1; int n = Math.Min(crops.Count, slots); var pngs = new byte[slots][]; for (int f = 0; f < n; f++) { byte[] png = null; yield return DownloadBytes(WithParams($"{config.ApiBase}/u/{crops[f]}.png", $"w={info.W}&h={info.H}&b={nb}"), b => png = b); if (png == null || png.Length == 0) { NotifyErr(player, "下载失败,亮度未改"); yield break; } pngs[f] = png; } for (int f = n; f < slots; f++) pngs[f] = pngs[f % n]; if (neon == null || neon.IsDestroyed) { NotifyErr(player, "牌子已不在了"); yield break; } try { WriteNeonFrames(neon, pngs); PowerNeon(neon); // 不带 speed=保持现循环速度 } catch (Exception ex) { if (config.Debug) PrintWarning($"重调亮度失败: {ex.Message}"); NotifyErr(player, "调亮度失败,请重试"); yield break; } info.B = nb; SavePainted(); NotifyOk(player, $"亮度已调到 {nb}/10"); if (player == null || !player.IsConnected) yield break; bool authed = player.IsAdmin || PlayerCanEdit(neon, player); yield return OpenEntityCui(player, eid, info, authed); } [ConsoleCommand("smartsign.ent.speed")] private void CcEntSpeed(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (!EntTarget(p, out var eid, out var info)) return; var neon = FindSign(eid) as NeonSign; if (neon == null) return; if (!p.IsAdmin && !PlayerCanEdit(neon, p)) { NotifyErr(p, "你没有编辑权限"); return; } float step = arg.GetInt(0, 0) >= 0 ? 0.5f : -0.5f; bool isWall = !string.IsNullOrEmpty(info.Wall); if (isWall) { if (info.Spd <= 0f) { NotifyErr(p, "这面墙是静态矩阵,没有动画速度"); return; } float ns = Mathf.Clamp(info.Spd + step, 0.5f, 10f); if (Mathf.Approximately(ns, info.Spd)) { Notify(p, "速度已到边界"); return; } var cells = new List(); foreach (var kv in painted) if (kv.Value.Wall == info.Wall) { if (FindSign(kv.Key) is NeonSign cell && !cell.IsDestroyed) cells.Add(cell); kv.Value.Spd = ns; } StartWallAnim(info.Wall, cells, ns); SavePainted(); NotifyOk(p, $"整面墙速度已调到 {ns:0.#}s/帧"); } else { if (neon.textureIDs == null || neon.textureIDs.Length <= 1 || info.AllCrops == null || info.AllCrops.Count <= 1) { NotifyErr(p, "静态图没有动画速度"); return; } float cur = neon.animationSpeed > 0 ? neon.animationSpeed : 3f; float ns = Mathf.Clamp(cur + step, 0.5f, 10f); if (Mathf.Approximately(ns, cur)) { Notify(p, "速度已到边界"); return; } PowerNeon(neon, ns); NotifyOk(p, $"速度已调到 {ns:0.#}s/帧"); } // 就地刷新 E CUI(速度不动图内容,直接重开) var sign = FindSign(eid); bool authed = sign != null && (p.IsAdmin || PlayerCanEdit(sign, p)); ServerMgr.Instance.StartCoroutine(OpenEntityCui(p, eid, info, authed)); } private IEnumerator EntSocial(BasePlayer player, string cropId, string kind) { string path = kind == "like" ? "/api/game/social/like" : "/api/game/social/report"; string payload = kind == "like" ? JsonConvert.SerializeObject(new { steamid = player.userID.ToString(), targetType = "img", targetId = cropId }) : JsonConvert.SerializeObject(new { steamid = player.userID.ToString(), cropId, reason = "游戏内举报" }); SimpleResp r = null; yield return PostJson(path, payload, x => r = x); if (r != null && r.ok) NotifyOk(player, kind == "like" ? "赞了!" : "已举报,管理员会处理"); else NotifyErr(player, r?.error ?? "操作失败"); if (player == null || !player.IsConnected) yield break; // 断线保护(下面用 player.userID + 重弹 CUI) // 刷新 CUI(重拉 /image) if (entityCuiTarget.TryGetValue(player.userID, out var eid) && painted.TryGetValue(eid, out var i2)) { var sign = FindSign(eid); bool authed = sign != null && (player.IsAdmin || PlayerCanEdit(sign, player)); yield return OpenEntityCui(player, eid, i2, authed); } } [ConsoleCommand("smartsign.ent.comment")] private void CcEntComment(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (!EntTarget(p, out _, out var info)) return; // v0.5.0:输入层叠在全屏 E接管之上,不销底层页(取消=留在页内) PromptText(p, "comment:" + info.CropId, "写条评论", "输入评论内容(最多 140 字)", 140, 30); } // 捕获评论输入(拦下该句不广播)。⚠️ Oxide 按 名+arity 绑 hook:必须 3 参签名(与生产 RustxzCore 一致), // 否则 hook 不触发 → 不仅评论捕获失效,玩家「当评论」输入的文字还会漏进公屏(return false 未执行)。 private object OnPlayerChat(BasePlayer player, string message, ConVar.Chat.ChatChannel channel) { if (player == null) return null; // 文本动作捕获旧路径(仅 config UseInputField=false 时会有 pendingText 项): // 相册名/改名、邮箱、验证码、评论、举报理由统一走 pendingText + actionToken。 if (pendingText.TryGetValue(player.userID, out var action)) { var text = (message ?? "").Trim(); pendingText.Remove(player.userID); if (text.Length == 0 || text == ".") { Reply(player, "已取消"); return false; } HandleInputAction(player, action, text); return false; } return null; } /// 分发文本输入动作(CUI 输入框回传 / 聊天捕获旧路径共用)。actionToken 语义见 PromptText。 private void HandleInputAction(BasePlayer player, string action, string text) { if (action == "album.create") { var body = new { steamid = player.userID.ToString(), action = "create", name = text }; ServerMgr.Instance.StartCoroutine(AlbumAction(player, body, $"已新建相册「{Trunc(text, 16)}」", "list")); } else if (action.StartsWith("album.rename:")) { var id = action.Substring("album.rename:".Length); var body = new { steamid = player.userID.ToString(), action = "rename", albumId = id, name = text }; ServerMgr.Instance.StartCoroutine(AlbumAction(player, body, "已改名", id)); } else if (action == "lib.search") { var st = fsLib.TryGetValue(player.userID, out var s) ? s : (fsLib[player.userID] = new FsLibState()); st.Query = text == "-" ? null : Trunc(text, 24); FsRedraw(player, "mine"); } else if (action == "email") ServerMgr.Instance.StartCoroutine(EmailSendCode(player, text)); else if (action == "emailcode") ServerMgr.Instance.StartCoroutine(EmailVerify(player, text)); else if (action.StartsWith("report:")) ServerMgr.Instance.StartCoroutine(SubmitReport(player, action.Substring("report:".Length), text)); else if (action.StartsWith("comment:")) ServerMgr.Instance.StartCoroutine(PostCommentAndRefresh(player, action.Substring("comment:".Length), text)); } /// 发评论 → 成功后就地刷新所在屏(E接管 CUI 或 作品详情),让新评论立刻出现在评论区。 private IEnumerator PostCommentAndRefresh(BasePlayer player, string cropId, string text) { var payload = JsonConvert.SerializeObject(new { steamid = player.userID.ToString(), cropId, text }); SimpleResp r = null; yield return PostJson("/api/game/social/comment", payload, x => r = x); if (r == null || !r.ok) { NotifyErr(player, r?.error ?? "评论失败"); yield break; } NotifyOk(player, "评论已发布"); if (player == null || !player.IsConnected) yield break; if (entityCuiTarget.TryGetValue(player.userID, out var eid) && painted.TryGetValue(eid, out var pinfo) && pinfo.CropId == cropId) { var sign = FindSign(eid); bool authed = sign != null && (player.IsAdmin || PlayerCanEdit(sign, player)); yield return OpenEntityCui(player, eid, pinfo, authed); } else if (imgTarget.TryGetValue(player.userID, out var cur) && cur == cropId) yield return OpenPlazaImage(player, cropId); } // ======================= F5 · CUI 文本输入弹层(相册名/邮箱/验证码/评论/举报理由复用) ======================= /// 统一文本输入入口:config UseInputField=true → 弹 CUI 输入框;false → 聊天捕获旧路径(保留兜底)。 /// actionToken:album.create / album.rename:<id> / email / emailcode / comment:<cropId> / report:<cropId>。 private void PromptText(BasePlayer p, string actionToken, string title, string hint, int charsLimit, int cancelSeconds, string prefill = null) { if (p == null) return; if (config.UseInputField) { ShowInputModal(p, title, hint, actionToken, charsLimit, prefill); return; } // 旧路径:聊天捕获(🔒 InputField 若真机不弹键盘/不回传,服主切此路径) pendingText[p.userID] = actionToken; Reply(p, $"{hint}(在聊天框输入,下一句,{cancelSeconds} 秒内;输入「.」取消)"); timer.Once(cancelSeconds, () => { if (pendingText.TryGetValue(p.userID, out var a) && a == actionToken) pendingText.Remove(p.userID); }); } /// DW 风小卡 + CuiInputField(NeedsKeyboard,回车/焦点回传,命令带 actionToken 自动追加文本)+ 取消钮。 /// 盖在当前界面上,取消/提交后仅销毁本弹层(底层界面留存)。🔒 真机验:键盘焦点、Enter 提交回传。 /// 文本输入层(v0.5.0,照 gen-smartsign-fs-input.mjs):半透 dim + 居中圆角 modal + 游戏内键盘输入框。 /// 提交=输入框内回车(CUI 按钮拿不到输入框文本,橙块=「回车 ↵ 确定」提示条)。 private void ShowInputModal(BasePlayer player, string title, string hint, string actionToken, int charsLimit, string prefill = null) { CuiHelper.DestroyUi(player, UiInput); var c = new CuiElementContainer(); c.Add(new CuiElement { Name = UiInput, Parent = "Overlay", Components = { new CuiImageComponent { Color = "0.05 0.06 0.08 0.82", Material = "assets/content/ui/uibackgroundblur-ingamemenu.mat" }, new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "1 1" }, new CuiNeedsCursorComponent(), }, }); Btn(c, UiInput + ".veil", UiInput, ColClear, "smartsign.input.cancel", "0 0", "1 1", null, 0, "", ""); const float mWpx = 0.28f * ScrW, mHpx = 0.27f * ScrH; // 358.4 × 194.4 string P = UiInput + ".m"; Rp(c, P, UiInput, FsNav, "0.36 0.35", "0.64 0.62", mWpx, mHpx, 14); Txt(c, P + ".t", P, title, FzTitle, TextAnchor.MiddleLeft, FsInk, FontBold, "0.06 0.82", "0.7 0.94"); Btn(c, P + ".x", P, ColClear, "smartsign.input.cancel", "0.9 0.82", "0.96 0.95", "✕", FzLabel, FsSub, FontReg); if (!string.IsNullOrEmpty(hint)) Txt(c, P + ".h", P, hint, FzSmall, TextAnchor.MiddleLeft, FsSub, FontReg, "0.06 0.72", "0.94 0.8"); // 输入框(圆角深底 + CuiInputField,游戏内键盘) Rp(c, P + ".f", P, FsThumb, "0.06 0.3", "0.94 0.68", 0.88f * mWpx, 0.38f * mHpx, 8); c.Add(new CuiElement { Name = P + ".f.in", Parent = P + ".f", Components = { new CuiInputFieldComponent { Text = prefill ?? "", Command = "smartsign.input.submit " + actionToken, CharsLimit = charsLimit, FontSize = FzBody, Font = FontReg, Color = FsInk, Align = TextAnchor.MiddleLeft, NeedsKeyboard = true, }, new CuiRectTransformComponent { AnchorMin = "0.035 0", AnchorMax = "0.97 1" }, }, }); // 橙确定条(回车提交提示)+ 取消 Rp(c, P + ".ok", P, FsAcc, "0.55 0.08", "0.75 0.24", 0.2f * mWpx, 0.16f * mHpx, 6); Txt(c, P + ".ok.t", P + ".ok", "回车 ↵ 确定", FzBody, TextAnchor.MiddleCenter, FsInk, FontBold, "0 0", "1 1"); Rp(c, P + ".cc.bg", P, FsCard2, "0.77 0.08", "0.94 0.24", 0.17f * mWpx, 0.16f * mHpx, 6); Btn(c, P + ".cc", P, ColClear, "smartsign.input.cancel", "0.77 0.08", "0.94 0.24", "取消", FzBody, FsSub, FontReg); CuiHelper.AddUi(player, c); } [ConsoleCommand("smartsign.input.cancel")] private void CcInputCancel(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p != null) CuiHelper.DestroyUi(p, UiInput); } [ConsoleCommand("smartsign.input.submit")] private void CcInputSubmit(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null) return; if (!arg.HasArgs(1)) { CuiHelper.DestroyUi(p, UiInput); return; } string token = arg.GetString(0); // 输入框文本作为附加参数尾随(Rust 引号包裹=单段 / 含空格=多段),逐段拼回并去首尾空白。 var sb = new StringBuilder(); for (int i = 1; arg.HasArgs(i + 1); i++) { if (sb.Length > 0) sb.Append(' '); sb.Append(arg.GetString(i)); } string text = sb.ToString().Trim(); CuiHelper.DestroyUi(p, UiInput); if (string.IsNullOrEmpty(text) || text == ".") { Reply(p, "已取消"); return; } HandleInputAction(p, token, text); } // ======================= F4 · 大图预览层(等比大图 + 场景化贴牌钮) ======================= /// 按 ctx 回查 sticker:mine=lastShown / album=lastAlbumDetail.images / plaza=lastPlazaImgs。 private Sticker ZoomSticker(BasePlayer p, string cropId, string ctx) { List src; if (ctx == "album") src = lastAlbumDetail.TryGetValue(p.userID, out var d) ? d?.images : null; else if (ctx == "plaza") src = lastPlazaImgs.TryGetValue(p.userID, out var pl) ? pl : null; else src = lastShown.TryGetValue(p.userID, out var sl) ? sl : null; return src?.Find(x => x.id == cropId); } /// 大图层(v0.5.0 全屏 letterbox):深底 veil + 居中等比大图(1080×520ref 内最大化)+ 名称/牌型 + 场景钮。 /// ctx:mine/img=仅看 / album=直接贴 / plaza=收藏后贴 + 详情评论。 private void ShowZoom(BasePlayer player, Sticker s, string ctx) { if (s == null) return; CuiHelper.DestroyUi(player, UiZoom); var c = new CuiElementContainer(); c.Add(new CuiElement { Name = UiZoom, Parent = "Overlay", Components = { new CuiImageComponent { Color = "0.05 0.055 0.07 0.96", Material = "assets/content/ui/uibackgroundblur-ingamemenu.mat" }, new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "1 1" }, new CuiNeedsCursorComponent(), }, }); Btn(c, UiZoom + ".veil", UiZoom, ColClear, "smartsign.zoom.close", "0 0", "1 1", null, 0, "", ""); // 大图(等比:1080×520 参考框内最大化,点锚 0.5 0.5 + 等比 offset 不变形) float w = s.w > 0 ? s.w : 1, h = s.h > 0 ? s.h : 1; float scale = Mathf.Min(1080f / w, 520f / h); float hw = w * scale / 2f, hh = h * scale / 2f; c.Add(new CuiElement { Name = UiZoom + ".img", Parent = UiZoom, Components = { new CuiRawImageComponent { Url = s.url }, new CuiRectTransformComponent { AnchorMin = "0.5 0.5", AnchorMax = "0.5 0.5", OffsetMin = F(-hw) + " " + F(-hh), OffsetMax = F(hw) + " " + F(hh) }, }, }); // 名称 + 牌型(顶部) Txt(c, UiZoom + ".name", UiZoom, Trunc(s.name, 24), FzTitle, TextAnchor.MiddleCenter, FsInk, FontBold, "0.2 0.925", "0.8 0.985"); string meta = string.IsNullOrEmpty(s.groupRatio) ? (s.label ?? "") : $"{s.label} · {s.groupRatio}"; Txt(c, UiZoom + ".meta", UiZoom, $"{meta} · ♥ {s.likes} · 💬 {s.comments}", FzBody, TextAnchor.MiddleCenter, FsSub, FontReg, "0.2 0.885", "0.8 0.922"); FsBtn(c, UiZoom + ".x", UiZoom, FsCard, "smartsign.zoom.close", 0.93f, 0.925f, 0.972f, 0.978f, "✕", FzLabel, FsSub, FontBold); // 场景化动作(底部):album=直接贴;plaza=收藏后贴 + 详情 if (ctx == "album") FsBtn(c, UiZoom + ".paint", UiZoom, FsAcc, $"smartsign.zoom.paint {s.id} album", 0.38f, 0.05f, 0.62f, 0.108f, "🖼 贴到面前的牌", FzLabel, FsInk, FontBold); else if (ctx == "plaza") { FsBtn(c, UiZoom + ".paint", UiZoom, FsAcc, $"smartsign.zoom.paint {s.id} plaza", 0.30f, 0.05f, 0.53f, 0.108f, "🖼 贴到面前的牌", FzLabel, FsInk, FontBold); FsBtn(c, UiZoom + ".detail", UiZoom, FsCard, $"smartsign.zoom.detail {s.id}", 0.55f, 0.05f, 0.70f, 0.108f, "详情 / 评论", FzBody, FsInk, FontBold); } Txt(c, UiZoom + ".tip", UiZoom, "点空白处关闭", FzSmall, TextAnchor.MiddleCenter, FsDim, FontReg, "0.4 0.008", "0.6 0.042"); CuiHelper.AddUi(player, c); } [ConsoleCommand("smartsign.zoom")] private void CcZoom(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null || !arg.HasArgs(1)) return; string cropId = arg.GetString(0), ctx = arg.HasArgs(2) ? arg.GetString(1) : "mine"; Sticker s; if (ctx == "img") { // 从作品详情放大:用 lastImg 缓存拼临时 sticker(详情可来自通知等任意上下文) var im = lastImg.TryGetValue(p.userID, out var ir) ? ir : null; if (im?.crop == null || im.crop.id != cropId) { Reply(p, "图信息已过期,请重新打开详情"); return; } s = new Sticker { id = im.crop.id, name = im.crop.name, label = im.crop.label, groupRatio = im.crop.groupRatio, url = im.crop.url, w = im.crop.w, h = im.crop.h, likes = im.likes?.total ?? 0, comments = im.commentCount }; } else s = ZoomSticker(p, cropId, ctx); if (s == null) { Reply(p, "图信息已过期,请重新打开列表"); return; } ShowZoom(p, s, ctx); } [ConsoleCommand("smartsign.zoom.close")] private void CcZoomClose(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p != null) CuiHelper.DestroyUi(p, UiZoom); } [ConsoleCommand("smartsign.zoom.paint")] private void CcZoomPaint(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null || !arg.HasArgs(2)) return; string cropId = arg.GetString(0), ctx = arg.GetString(1); CuiHelper.DestroyUi(p, UiZoom); if (ctx == "plaza") { ServerMgr.Instance.StartCoroutine(CollectAndPaint(p, cropId)); return; } var s = ZoomSticker(p, cropId, ctx); if (s == null) { Reply(p, "图信息已过期,请重新打开列表"); return; } ServerMgr.Instance.StartCoroutine(PaintFlow(p, s)); } [ConsoleCommand("smartsign.zoom.detail")] private void CcZoomDetail(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (p == null || !arg.HasArgs(1)) return; CuiHelper.DestroyUi(p, UiZoom); imgBack[p.userID] = "plaza:"; // 大图仅在广场册内可进详情;返回=回落广场册页 ServerMgr.Instance.StartCoroutine(OpenPlazaImage(p, arg.GetString(0))); // 广场单图详情(社交) } [ConsoleCommand("smartsign.ent.comments")] private void CcEntComments(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (!EntTarget(p, out _, out var info)) return; ServerMgr.Instance.StartCoroutine(GetJson($"/api/game/image/{info.CropId}?viewer={p.userID}", img => { if (img?.comments == null || img.comments.Count == 0) { Reply(p, "还没有评论"); return; } Reply(p, $"「{img.crop?.name}」的评论:"); foreach (var cm in img.comments) if (cm != null) p.ChatMessage($" {cm.author}:{cm.text}"); })); } [ConsoleCommand("smartsign.ent.detail")] private void CcEntDetail(ConsoleSystem.Arg arg) { var p = arg?.Player(); if (!EntTarget(p, out _, out var info)) return; // v0.5.0:直接叠开全屏作品详情(返回=回 E接管页) imgBack[p.userID] = "ent"; ServerMgr.Instance.StartCoroutine(OpenPlazaImage(p, info.CropId)); } // ======================= 远程下架轮询(S4.3) ======================= // 平台 admin「一键删实体」→ painted_sign.takedown=1;本插件轮询 GET /takedowns 拿到 → // 清除该牌的图(撤下违规内容,保留牌子本体,比销毁实体温和)→ POST /takedowns/ack 回执。 private IEnumerator PollTakedowns() { TakedownsResp resp = null; yield return GetJson($"/api/game/takedowns?server_key={UnityWebRequest.EscapeURL(ServerKey())}", r => resp = r); if (resp == null || !resp.ok || resp.takedowns == null || resp.takedowns.Count == 0) yield break; var acked = new List(); var cropSet = new HashSet(); foreach (var td in resp.takedowns) { if (td == null || string.IsNullOrEmpty(td.entityId)) continue; if (!string.IsNullOrEmpty(td.cropId)) cropSet.Add(td.cropId); var sign = FindSign(td.entityId); if (sign != null) { try { sign.Kill(); } // 下架=直接摧毁实体(用户拍板:不留牌,违规内容连载体一起清) catch (Exception ex) { if (config.Debug) PrintWarning($"撤下摧毁失败: {ex.Message}"); } } if (painted.TryGetValue(td.entityId, out var pi0) && !string.IsNullOrEmpty(pi0.Wall)) StopWallAnim(pi0.Wall); // 实体不在(已拆/未加载)也回执:违规图已不在游戏内,视为已撤下 painted.Remove(td.entityId); acked.Add(td.entityId); } // 本地扩散:凡「用到」被下架 crop 的实体(多帧动图/矩阵墙每一块)一并清空—— // 平台只知道报备过的实体,本地 painted.AllCrops 补齐剩余(“所有服务器所有用到该图的实体全清”)。 if (cropSet.Count > 0) { var sweep = new List(); foreach (var kv in painted) { var info = kv.Value; bool hit = cropSet.Contains(info.CropId); if (!hit && info.AllCrops != null) foreach (var cid in info.AllCrops) if (cropSet.Contains(cid)) { hit = true; break; } if (hit) sweep.Add(kv.Key); } foreach (var eid in sweep) { var s = FindSign(eid); if (s != null) { try { s.Kill(); } // 扩散:用到该图的实体(多帧/整墙)同样直接摧毁 catch (Exception ex) { if (config.Debug) PrintWarning($"扩散摧毁失败: {ex.Message}"); } } if (painted.TryGetValue(eid, out var pi1) && !string.IsNullOrEmpty(pi1.Wall)) StopWallAnim(pi1.Wall); painted.Remove(eid); } if (config.Debug && sweep.Count > 0) PrintWarning($"[SmartSign] 下架扩散摧毁 {sweep.Count} 块关联实体"); } SavePainted(); if (acked.Count > 0) { var payload = JsonConvert.SerializeObject(new { serverKey = ServerKey(), entityIds = acked }); yield return PostJson("/api/game/takedowns/ack", payload, _ => { }); if (config.Debug) PrintWarning($"[SmartSign] 远程撤下 {acked.Count} 张违规贴图"); } } /// 游戏内播报(举报受理/处罚/申诉结果):只播给在线玩家(NCP 三通道),在线才 ack; /// 离线不播不 ack——平台侧 10 分钟过期自动作废(离线玩家走 站内信+邮件)。 private IEnumerator PollNotices() { NoticesResp resp = null; yield return GetJson("/api/game/notices", r => resp = r); if (resp == null || !resp.ok || resp.notices == null || resp.notices.Count == 0) yield break; var done = new List(); foreach (var no in resp.notices) { if (no == null || string.IsNullOrEmpty(no.steamid) || string.IsNullOrEmpty(no.text)) continue; if (!ulong.TryParse(no.steamid, out var uid)) continue; var pl = BasePlayer.FindByID(uid); if (pl == null || !pl.IsConnected) continue; // 不在线:不播报、不 ack(另一台服在线则由那台播) Notify(pl, no.text, Mathf.Clamp(no.ntype, 0, 3)); done.Add(no.id); } if (done.Count > 0) yield return PostJson("/api/game/notices", JsonConvert.SerializeObject(new { ack = done }), _ => { }); } // ======================= 牌型纹理尺寸表(贴牌尺寸匹配用;自 mengxin/src/lib/signTypes.ts 生成) ======================= private struct Sz { public int W, H; public string Group; public Sz(int w, int h, string g) { W = w; H = h; Group = g; } } private static readonly Dictionary SignSizes = new Dictionary { // 共 49 项(自 mengxin/src/lib/signTypes.ts 生成,勿手改) { "sign.pictureframe.xxl", new Sz(1024, 512, "w2") }, { "sign.small.wood", new Sz(256, 128, "w2") }, { "sign.medium.wood", new Sz(512, 256, "w2") }, { "sign.large.wood", new Sz(512, 256, "w2") }, { "sign.hanging.ornate", new Sz(512, 256, "w2") }, { "sign.post.single", new Sz(256, 128, "w2") }, { "sign.post.town", new Sz(512, 256, "w2") }, { "sign.post.town.roof", new Sz(512, 256, "w2") }, { "window.paintable", new Sz(512, 256, "w2") }, { "goldframe.xxl", new Sz(1024, 512, "w2") }, { "lightupframe.xxl", new Sz(1024, 512, "w2") }, { "scrapframe.xxl", new Sz(1024, 512, "w2") }, { "sign.artistcanvas.xxl", new Sz(1024, 512, "w2") }, { "sign.pictureframe.xl", new Sz(512, 512, "sq") }, { "sign.post.double", new Sz(512, 512, "sq") }, { "sign.neon.xl", new Sz(256, 256, "sq") }, { "sign.neon.xl.animated", new Sz(256, 256, "sq") }, { "sign.neon.125x125", new Sz(128, 128, "sq") }, { "spinner.wheel.deployed", new Sz(512, 512, "sq") }, { "carvable.pumpkin", new Sz(256, 256, "sq") }, { "lightupframe.xl", new Sz(512, 512, "sq") }, { "goldframe.xl", new Sz(512, 512, "sq") }, { "scrapframe.xl", new Sz(512, 512, "sq") }, { "sign.artistcanvas.xl", new Sz(512, 512, "sq") }, { "paintable_reactive_target.deployed", new Sz(256, 256, "sq") }, { "sign.hanging.banner.large", new Sz(256, 1024, "t14") }, { "sign.pole.banner.large", new Sz(256, 1024, "t14") }, { "sign.pictureframe.tall", new Sz(128, 512, "t14") }, { "goldframe.small", new Sz(256, 1024, "t14") }, { "scrapframe.large", new Sz(256, 1024, "t14") }, { "sign.pictureframe.landscape", new Sz(256, 192, "l43") }, { "photoframe.large", new Sz(320, 240, "l43") }, { "photoframe.landscape", new Sz(320, 240, "l43") }, { "sign.artistcanvas.m", new Sz(320, 240, "l43") }, { "sign.hanging", new Sz(256, 512, "h12") }, { "sign.neon.125x215", new Sz(256, 128, "w2") }, { "sign.neon.125x215.animated", new Sz(256, 128, "w2") }, { "sign.huge.wood", new Sz(1024, 256, "w41") }, { "lightupframe.small", new Sz(128, 175, "s175") }, { "lightupframe.standing", new Sz(128, 175, "s175") }, { "scrapframe.small", new Sz(128, 175, "s175") }, { "goldframe.standing", new Sz(128, 320, "t25") }, { "scrapframe.standing", new Sz(128, 320, "t25") }, { "sign.artistcanvas.l", new Sz(256, 640, "t25") }, { "lightupframe.large", new Sz(320, 256, "w54") }, { "goldframe.large", new Sz(320, 256, "w54") }, { "photoframe.portrait", new Sz(320, 384, "p56") }, { "sign.pictureframe.portrait", new Sz(205, 256, "pf") }, { "sign.artistcanvas.xs", new Sz(192, 256, "c34") }, }; // ======================= JSON 模型(对齐 /api/game/* 契约) ======================= private class UserResp { public bool ok { get; set; } public string steamId { get; set; } public string name { get; set; } public bool banned { get; set; } public string bannedReason { get; set; } public int reputation { get; set; } public Quota quota { get; set; } } private class Quota { public long used { get; set; } public long quota { get; set; } public long free { get; set; } public long granted { get; set; } } private class StickersResp { public bool ok { get; set; } public List stickers { get; set; } } private class PaintResp { public bool ok { get; set; } public string error { get; set; } public Sticker crop { get; set; } public string signLabel { get; set; } } // F2:收藏并贴牌(POST /api/game/collect 回执,sticker=新成品供 PaintFlow) private class CollectResp { public bool ok { get; set; } public string error { get; set; } public bool deduped { get; set; } public string albumName { get; set; } public Sticker sticker { get; set; } } private class SimpleResp { public bool ok { get; set; } public string error { get; set; } public string id { get; set; } public int added { get; set; } } // —— 我的方案(P2;对齐 GET /api/game/sets 契约) —— private class SetsResp { public bool ok { get; set; } public List animSets { get; set; } public List matrixSets { get; set; } public List animMatrixSets { get; set; } } private class GameFrame { public string cropId { get; set; } public string url { get; set; } public int brightness { get; set; } // 每帧独立亮度(0=用整体) } private class GameAnimSet { public string id { get; set; } public string name { get; set; } public string prefab { get; set; } public string label { get; set; } public float speed { get; set; } public int brightness { get; set; } public string albumId { get; set; } public List frames { get; set; } } private class GameMatrixSet { public string id { get; set; } public string name { get; set; } public string tilePrefab { get; set; } public string label { get; set; } public int cols { get; set; } public int rows { get; set; } public int brightness { get; set; } public string albumId { get; set; } public string sourceCropId { get; set; } public List tiles { get; set; } } private class GameAnimMatrixSet { public string id { get; set; } public string name { get; set; } public string tilePrefab { get; set; } public string label { get; set; } public int cols { get; set; } public int rows { get; set; } public float speed { get; set; } public int brightness { get; set; } public List frameBrightness { get; set; } // 每帧独立亮度 public string albumId { get; set; } public int frameCount { get; set; } public List frameCropIds { get; set; } public List> tilesByFrame { get; set; } } // —— 相册管理(①) —— private class AlbumsResp { public bool ok { get; set; } public List albums { get; set; } } private class AlbumItem { public string id { get; set; } public string name { get; set; } [JsonProperty("public")] public int isPublic { get; set; } public int count { get; set; } public string coverCropId { get; set; } public int coverW { get; set; } public int coverH { get; set; } } private class AlbumDetailResp { public bool ok { get; set; } public AlbumInfo album { get; set; } public List images { get; set; } } private class AlbumInfo { public string id { get; set; } public string name { get; set; } [JsonProperty("public")] public bool isPublic { get; set; } public string coverCropId { get; set; } public string owner { get; set; } public int heat { get; set; } } // —— 广场深浏览(⑥) —— private class PlazaResp { public bool ok { get; set; } public List albums { get; set; } } private class PlazaAlbum { public string id { get; set; } public string name { get; set; } public int count { get; set; } public string coverUrl { get; set; } public int coverW { get; set; } public int coverH { get; set; } public string owner { get; set; } public int heat { get; set; } } // —— 通知中心(④) —— private class NotifResp { public bool ok { get; set; } public List notifications { get; set; } public int unread { get; set; } } private class NotifItem { public string id { get; set; } public string type { get; set; } public string text { get; set; } public NotifNav nav { get; set; } public string refId { get; set; } public bool read { get; set; } } private class NotifNav { public string kind { get; set; } public string id { get; set; } } // —— 邮箱绑定(③) —— private class EmailResp { public bool ok { get; set; } public string error { get; set; } public string email { get; set; } public string pendingEmail { get; set; } public bool verified { get; set; } } private class ImageResp { public bool ok { get; set; } public ImgCrop crop { get; set; } public bool isPrivate { get; set; } public bool isOwner { get; set; } public ImgCan can { get; set; } public ImgLikes likes { get; set; } public int commentCount { get; set; } public List comments { get; set; } public List supports { get; set; } } private class ImgCrop { public string id { get; set; } public string name { get; set; } public string caption { get; set; } // F12 说说(可选) public string label { get; set; } public string url { get; set; } public string group { get; set; } // 比例组(贴自己图直接 PaintFlow 时匹配牌型;后端 image 端点已下发) public string groupRatio { get; set; } public int w { get; set; } public int h { get; set; } public ImgOwner owner { get; set; } } private class ImgOwner { public string steamId { get; set; } public string name { get; set; } } private class ImgCan { public bool like { get; set; } public bool comment { get; set; } public bool report { get; set; } } private class ImgLikes { public int total { get; set; } } private class ImgComment { public string author { get; set; } public string steamId { get; set; } // 头像:/api/avatar/{steamId}(无则首字回退) public string text { get; set; } } private class ImgSupport { public string label { get; set; } public int w { get; set; } public int h { get; set; } public string prefab { get; set; } // 适用牌型图标:/signs/{prefab}.png } private class TakedownsResp { public bool ok { get; set; } public List takedowns { get; set; } } // —— 游戏内播报(举报受理/处罚/申诉结果) —— private class NoticesResp { public bool ok { get; set; } public List notices { get; set; } } private class GameNotice { public string id { get; set; } public string steamid { get; set; } public int ntype { get; set; } public string text { get; set; } } private class Takedown { public string entityId { get; set; } public string signType { get; set; } public string cropId { get; set; } } private class Sticker { public string id { get; set; } public string name { get; set; } public string caption { get; set; } // F12 说说(可选) public string prefab { get; set; } public string label { get; set; } public string group { get; set; } public string groupRatio { get; set; } public string url { get; set; } public int w { get; set; } public int h { get; set; } public int likes { get; set; } public int comments { get; set; } } } } namespace NDShim_SmartSign { public static class SetFlagCompat { public static void SetFlag(this BaseEntity e, BaseEntity.Flags f, bool b, bool recursive = false, bool networkupdate = true) { if (networkupdate) { using (e.StartSetFlags(BaseEntity.FlagsUpdateMode.SendNetworkUpdate)) e.SetFlagLocal(f, b, recursive); } else e.SetFlagLocal(f, b, recursive); } } }