using System; using System.Collections.Generic; using Logic.Multilingual; using Steamworks; using TH1_Core.Events; using TH1_Logic.Config; using TMPro; using UnityEngine; using UnityEngine.UI; using ConfigManager = TH1_Logic.Config.ConfigManager; namespace TH1_UI.View.Outside { /// /// Mod 管理界面:mainOption 切换 SetLanguagePanel / ModPanel。 /// 本期主要实现 ModPanel:左侧列表 + 右侧详情,支持按 MultilingualType 筛选。 /// public class UIOutsideModView : Base.View { // 单次查询/列表上限,超出截断(Steam 一次最多返回 50 条/页,本 UI 当前只展示首页) public const int MaxListItemCount = 100; [Header("通用按钮")] public Button CloseButton; [Header("主分页")] public UIOutsideSelectOptionGroupMono MainOption; // 0=SetLanguagePanel, 1=ModPanel, 2=MakeModePanel public GameObject SetLanguagePanel; public GameObject ModPanel; public GameObject MakeModePanel; [Header("MakeModePanel - 导出")] // 导出参数 public TMP_Dropdown ExportTargetLanguageDropdown; // 目标语言(mod_info.targetLanguage) public TMP_Dropdown ExportReferenceLanguageDropdown; // 参考语言(CSV 第3列) public TMP_InputField ExportModNameInput; // mod 名(留空自动生成) // 两个导出按钮 public Button ExportCoreButton; // 导出核心文案(暂未实现,待补 IsCore 字段) public Button ExportFullButton; // 导出全量文案 // 每个按钮各自的状态/反馈文本("正在导出中..." / "导出成功!路径:...") public TextMeshProUGUI ExportCoreStatusText; public TextMeshProUGUI ExportFullStatusText; public Button ExportOpenFolderButton; // 在文件浏览器中打开导出目录(最近一次成功导出) private string _lastExportPath; [Header("MakeModePanel - 上传到 Workshop")] // 选哪个本地 mod 文件夹(自动列出 WorkshopMods/ 下所有目录) public TMP_Dropdown UploadModFolderDropdown; // 上传 metadata public TMP_InputField UploadTitleInput; public TMP_InputField UploadDescriptionInput; // 检测到的预览图状态文字("preview.png ✅ 已检测" / "未提供") public TextMeshProUGUI UploadPreviewStatusText; // 创建新 / 更新已有 互斥 toggle public Toggle UploadCreateNewToggle; public Toggle UploadUpdateExistingToggle; // 仅在"更新已有"模式可见,列出我已上传的 mod 让玩家选 public TMP_Dropdown UploadUpdateTargetDropdown; // 操作按钮 + 状态 public Button UploadStartButton; public TextMeshProUGUI UploadStatusText; public Button UploadOpenInSteamButton; // 上传成功后亮起,点了跳浏览器 // 协议未签时显示 public GameObject UploadAgreementHint; public Button UploadAgreementOpenButton; [Header("MakeModePanel - 我已上传的 Mod 列表")] public Transform UploadedModListContent; public GameObject UploadedModListItemPrefab; // 挂 UIOutsideUploadedModListItemMono public Button UploadedModRefreshButton; public TextMeshProUGUI UploadedModListStatusText; // "查询中..." / "共 N 项" [Header("SetLanguagePanel - 当前语言")] // 当前正在使用的语言下拉。改动 = 立即 ChangedMultilingual,等价于设置面板里切语言。 public TMP_Dropdown CurrentLanguageDropdown; [Header("SetLanguagePanel - 正在使用 (左)")] // 当前选中语言启用的 mod 列表父节点。 // 显示顺序:从上到下 = 高优先级 → 低优先级,最底部固定一个不可删除的"系统项" public Transform ActiveModListContent; [Header("SetLanguagePanel - 配置入口")] public Button StartConfigButton; // 默认显示,点击后进入 Config 模式 public GameObject ConfigPart; // 默认隐藏,进入 Config 模式后整体显示 [Header("SetLanguagePanel - ConfigPart")] public Transform InstalledModListContent; // 已安装 mod 列表父节点 public Toggle ShowAllToggle; // 勾选=全部已安装;不勾=仅匹配当前语言(默认不勾) public Button SaveConfigButton; // 保存并应用 public Button CancelConfigButton; // 取消(丢弃副本) [Header("ModPanel - 左侧列表")] public Transform ListContent; // 列表行的 parent public GameObject ListItemPrefab; // 挂 UIOutsideModListItemMono public Button StartQueryButton; // 触发 Steam 工坊查询 public TextMeshProUGUI QueryStatusText; // "查询中..." / "未查询" / "已完成 N 条" public TMP_Dropdown LanguageFilterDropdown; // None=不限 + 41 种语言(自己填或代码 init) [Header("ModPanel - 右侧详情")] public GameObject DetailRoot; // 整个详情容器(无选中时隐藏) public TextMeshProUGUI TitleText; public TextMeshProUGUI MainLanguageTagText; public TextMeshProUGUI AuthorText; public TextMeshProUGUI TimeText; public TextMeshProUGUI DescText; [Header("ModPanel - 详情订阅状态")] public GameObject SubscribedHint; // "已订阅" 提示 public GameObject InstalledHint; // "已安装" 提示 public GameObject UnsubscribedHint; // "未订阅" 提示 public Button SubscribeButton; public Button UnsubscribeButton; // 关闭时执行的委托 public ViDelegateAssisstant.Dele OnBtnCloseClick; // 内部数据 private readonly List _allMods = new List(); private readonly List _itemPool = new List(); private MultilingualType _languageFilter = MultilingualType.None; private UIOutsideModListData _selected; private bool _hasQueriedWorkshop; // 当前 ModPanel 列表过滤后实际渲染的条数。RefreshList 写入,UpdateQueryStatus 读取 // 给"已加载 N/Total"做分子,确保切语言筛选/订阅后状态文字立刻同步当前所见。 private int _visibleModCount; // SetLanguagePanel 内部数据 // 当前编辑/展示的目标语言(跟 MultilingualManager.CurrentType 同步,dropdown 改它) private MultilingualType _editingLanguage = MultilingualType.EN; // 是否处于 Config 模式(点了 StartConfig,正在编辑 mod 列表) private bool _isInConfigMode; // Config 模式下编辑用的副本(保存才写回 ConfigManager.Config) // 仅缓存当前编辑语言这一份;切语言或退出会丢 private List _editingModPaths = new List(); // "已安装 mod" 数据:本地 + 已订阅安装。供 Active 列表查 LocalFolder 复用 private readonly List _availableMods = new List(); private readonly List _activeItemPool = new List(); private readonly List _availableItemPool = new List(); // CurrentLanguageDropdown 的索引到 MultilingualType 的映射 private readonly List _languageOptions = new List(); // MakeModPanel 两个 Dropdown 的索引到 MultilingualType 的映射(与 _languageOptions 内容相同,但分开持有避免耦合) private readonly List _exportLanguageOptions = new List(); // 上传子模块的内部数据 private readonly List _uploadFolderOptions = new List(); // dropdown 索引 → 文件夹绝对路径 private readonly List _uploadUpdateTargets = new List(); // dropdown 索引 → fileId private PublishedFileId_t _lastUploadedFileId; private readonly List _uploadedItemPool = new List(); // Create/Update 两个 toggle 的互斥:记录"上一次哪个是激活的",用于判断点击事件的主动方 private bool _uploadModeIsUpdate = false; // false = Create, true = Update // 5 种主语言常量(其余语言走系统项 = Default English) private static readonly MultilingualType[] PrimaryLanguages = { MultilingualType.ZH, MultilingualType.TDZH, MultilingualType.EN, MultilingualType.JP, MultilingualType.KR, }; protected override void OnInit() { base.OnInit(); if (CloseButton != null) { CloseButton.onClick.RemoveAllListeners(); CloseButton.onClick.AddListener(OnClose); } if (StartQueryButton != null) { StartQueryButton.onClick.RemoveAllListeners(); StartQueryButton.onClick.AddListener(OnStartQueryClicked); } if (SubscribeButton != null) { SubscribeButton.onClick.RemoveAllListeners(); SubscribeButton.onClick.AddListener(OnSubscribeClicked); } if (UnsubscribeButton != null) { UnsubscribeButton.onClick.RemoveAllListeners(); UnsubscribeButton.onClick.AddListener(OnUnsubscribeClicked); } if (StartConfigButton != null) { StartConfigButton.onClick.RemoveAllListeners(); StartConfigButton.onClick.AddListener(OnStartConfigClicked); } if (SaveConfigButton != null) { SaveConfigButton.onClick.RemoveAllListeners(); SaveConfigButton.onClick.AddListener(OnSaveConfigClicked); } if (CancelConfigButton != null) { CancelConfigButton.onClick.RemoveAllListeners(); CancelConfigButton.onClick.AddListener(OnCancelConfigClicked); } if (ShowAllToggle != null) { ShowAllToggle.onValueChanged.RemoveAllListeners(); ShowAllToggle.onValueChanged.AddListener(_ => RenderAvailableList()); } if (ExportCoreButton != null) { ExportCoreButton.onClick.RemoveAllListeners(); ExportCoreButton.onClick.AddListener(OnExportCoreClicked); } if (ExportFullButton != null) { ExportFullButton.onClick.RemoveAllListeners(); ExportFullButton.onClick.AddListener(OnExportFullClicked); } if (ExportOpenFolderButton != null) { ExportOpenFolderButton.onClick.RemoveAllListeners(); ExportOpenFolderButton.onClick.AddListener(OnExportOpenFolderClicked); } // 上传子模块按钮 if (UploadStartButton != null) { UploadStartButton.onClick.RemoveAllListeners(); UploadStartButton.onClick.AddListener(OnUploadStartClicked); } if (UploadOpenInSteamButton != null) { UploadOpenInSteamButton.onClick.RemoveAllListeners(); UploadOpenInSteamButton.onClick.AddListener(OnUploadOpenInSteamClicked); } if (UploadAgreementOpenButton != null) { UploadAgreementOpenButton.onClick.RemoveAllListeners(); UploadAgreementOpenButton.onClick.AddListener(OnUploadAgreementOpenClicked); } if (UploadCreateNewToggle != null) { UploadCreateNewToggle.onValueChanged.RemoveAllListeners(); UploadCreateNewToggle.onValueChanged.AddListener(OnUploadModeToggleChanged); } if (UploadUpdateExistingToggle != null) { UploadUpdateExistingToggle.onValueChanged.RemoveAllListeners(); UploadUpdateExistingToggle.onValueChanged.AddListener(OnUploadModeToggleChanged); } if (UploadedModRefreshButton != null) { UploadedModRefreshButton.onClick.RemoveAllListeners(); UploadedModRefreshButton.onClick.AddListener(OnUploadedModRefreshClicked); } // Steam 异步回调统一通过 Browser 注册到 OnQueryCompleted/OnSubscribeCompleted WorkshopModBrowser.Instance.OnQueryCompleted += OnWorkshopQueryCompleted; WorkshopModBrowser.Instance.OnSubscribeCompleted += OnSteamSubscribeCompleted; WorkshopModBrowser.Instance.OnUnsubscribeCompleted += OnSteamUnsubscribeCompleted; WorkshopModBrowser.Instance.OnUserModsQueryCompleted += OnUserModsQueryCompleted; } private void OnDestroy() { WorkshopModBrowser.Instance.OnQueryCompleted -= OnWorkshopQueryCompleted; WorkshopModBrowser.Instance.OnSubscribeCompleted -= OnSteamSubscribeCompleted; WorkshopModBrowser.Instance.OnUnsubscribeCompleted -= OnSteamUnsubscribeCompleted; WorkshopModBrowser.Instance.OnUserModsQueryCompleted -= OnUserModsQueryCompleted; } // Steam 回调需要主循环调用 RunCallbacks,本 View 处于活动期间持续轮询 private void Update() { WorkshopModBrowser.Instance.RunCallbacks(); // Uploader 是基于轮询 IsAPICallCompleted 的,每帧 Poll 推动状态机 if (WorkshopModUploader.Instance.IsBusy) { WorkshopModUploader.Instance.Poll(); RefreshUploadButtonInteractable(); if (UploadStatusText != null) UploadStatusText.text = WorkshopModUploader.Instance.StatusMessage; } } public void SetContent(ShowUIOutsideMod evt) { // 必须先禁用 Dropdown caption/item Label 上的 MultilingualTextMono: // prefab 残留 ID 会在切语言时覆盖代码 AddOptions 写入的文字。Init 内 AddOptions // 会立刻触发 captionText 重绘,所以 Ban 要先于 Init 执行。 DisableDropdownLabelMultilingual(CurrentLanguageDropdown); DisableDropdownLabelMultilingual(LanguageFilterDropdown); DisableDropdownLabelMultilingual(ExportTargetLanguageDropdown); DisableDropdownLabelMultilingual(ExportReferenceLanguageDropdown); DisableDropdownLabelMultilingual(UploadModFolderDropdown); DisableDropdownLabelMultilingual(UploadUpdateTargetDropdown); InitMainOption(); InitLanguageFilterDropdown(); InitCurrentLanguageDropdown(); InitExportLanguageDropdowns(); _hasQueriedWorkshop = false; ClearSelection(); RefreshList(); // 内部已联动 UpdateQueryStatus // 进入面板默认非 Config 模式,编辑语言 = 当前正在用的语言 ExitConfigMode(discardChanges: true); RefreshLanguagePanel(); ResetExportStatus(); InitUploadModule(); QueryUserModsAndRefresh(); } // 在 Controller 调用 Close 时会先调用这里 public void OnCloseView() { } public void OnClose() { OnBtnCloseClick?.Invoke(); } // ── mainOption 初始化 ── private void InitMainOption() { if (MainOption == null) return; // 先把目标面板可见性切到默认 (Option 0 = SetLanguagePanel), // 否则 SelectOptionGroup.Init -> Select -> LayoutRebuilder // 在 inactive 状态下计算 anchoredPosition 会拿到错误值,导致 SelectBG 跑偏。 ApplyMainOptionVisibility(0); MainOption.Init(0); // 默认选 Option 0 = SetLanguagePanel MainOption.OnOptionClicked = OnMainOptionClicked; } private void OnMainOptionClicked(uint idx) { ApplyMainOptionVisibility(idx); // 切回语言面板时强制退出 Config 模式 + 重新拉一遍(覆盖期间订阅/安装变化) if (idx == 0) { ExitConfigMode(discardChanges: true); RefreshLanguagePanel(); } // 切到 Mod 面板时也重拉一次:语言面板里订阅/取消订阅会改 mod 数据, // 否则 ModPanel 列表 + QueryStatusText 都会停留在旧值 else if (idx == 1) { RefreshList(); } // 切到 MakeMode 面板时重置上传子模块状态: // 否则上次失败留下的 UploadAgreementHint / OpenInSteam 会一直挂在面板上 else if (idx == 2) { InitUploadModule(); } } private void ApplyMainOptionVisibility(uint idx) { if (SetLanguagePanel != null) SetLanguagePanel.SetActive(idx == 0); if (ModPanel != null) ModPanel.SetActive(idx == 1); if (MakeModePanel != null) MakeModePanel.SetActive(idx == 2); } // ── 语言筛选下拉 ── // 注:Inspector 里也可以预先填好 options,这里用代码兜底 private void InitLanguageFilterDropdown() { if (LanguageFilterDropdown == null) return; LanguageFilterDropdown.ClearOptions(); var options = new List { "All" }; var values = new List { MultilingualType.None }; foreach (MultilingualType t in Enum.GetValues(typeof(MultilingualType))) { if (t == MultilingualType.None || t == MultilingualType.Max) continue; options.Add(GetLanguageDisplayName(t)); values.Add(t); } LanguageFilterDropdown.AddOptions(options); LanguageFilterDropdown.value = 0; _languageFilter = MultilingualType.None; // 兜底刷一次 caption(避免 captionText 残留 prefab 上的占位文本) if (LanguageFilterDropdown.captionText != null) { LanguageFilterDropdown.captionText.text = options[0]; } LanguageFilterDropdown.onValueChanged.RemoveAllListeners(); LanguageFilterDropdown.onValueChanged.AddListener(i => { _languageFilter = i >= 0 && i < values.Count ? values[i] : MultilingualType.None; RefreshList(); }); } // ── 列表刷新 ── // 重新拉取本地+订阅 Mod,与已查询的 Workshop 结果合并,按筛选条件渲染 private void RefreshList() { RebuildAllModsCache(); var filtered = FilterMods(_allMods, _languageFilter); if (filtered.Count > MaxListItemCount) filtered = filtered.GetRange(0, MaxListItemCount); RenderList(filtered); _visibleModCount = filtered.Count; // 列表为空 → 清空详情; // 当前选中项已不在过滤结果里 → 默认选中首项; // 当前选中项仍在 → 保持选中。 if (filtered.Count == 0) { ClearSelection(); } else if (_selected == null || !filtered.Contains(_selected)) { OnListItemSelected(filtered[0]); } else { // 已选项还在,但 _itemPool 顺序可能变了,重新刷一遍 highlight RefreshHighlight(); } // 列表唯一变更入口在这里,状态文字一并联动,避免分散在多处遗漏 UpdateQueryStatus(); } private void RebuildAllModsCache() { _allMods.Clear(); // 1. 本地 Mod(包括已安装的订阅 Mod,因为 GetSubscribedModPaths 也读 mod_info.json) int beforeLocal = _allMods.Count; AppendLocalMods(_allMods, WorkshopModLoader.GetLocalModPaths(), ModListItemSource.Local); int localCount = _allMods.Count - beforeLocal; int beforeSubscribed = _allMods.Count; AppendSubscribedMods(_allMods, WorkshopModLoader.GetSubscribedModEntries()); int subscribedCount = _allMods.Count - beforeSubscribed; // 诊断日志:本地 + 订阅集合明细(fileId / title / folder) var diagSb = new System.Text.StringBuilder(); diagSb.Append($"[UIOutsideMod] RebuildAllModsCache: Local={localCount} Subscribed={subscribedCount} "); diagSb.Append($"hasQueriedWorkshop={_hasQueriedWorkshop} BrowserResultsCount={WorkshopModBrowser.Instance.Results.Count}\n"); diagSb.Append(" --- 本地/订阅集合 ---\n"); for (int i = 0; i < _allMods.Count; i++) { var m = _allMods[i]; diagSb.Append($" [{i}] Source={m.Source} fileId={m.SteamFileId.m_PublishedFileId} title=\"{m.Title}\" folder=\"{m.LocalFolder}\"\n"); } // 2. Steam 工坊查询结果(仅在用户点过查询按钮后才有) // 注意:已订阅的 mod 已经在第 1 步用 Subscribed 来源出现过,这里按 FileId 去重 —— 命中则把工坊元数据合并到那条本地记录上,不再单独 add。 if (_hasQueriedWorkshop) { diagSb.Append(" --- Workshop 查询结果遍历 ---\n"); int idxInResults = 0; foreach (var item in WorkshopModBrowser.Instance.Results) { // 先按 FileId 去重(订阅版本场景命中),失败再 fallback 用 title 去重(本地导出版本 fileId=0, // 但与 Steam 上传版本 title 完全相同 —— 应当视为同一 mod 合并) var existing = FindByFileId(_allMods, item.FileId); string matchReason = "FileId"; if (existing == null && !string.IsNullOrEmpty(item.Title)) { existing = FindByTitle(_allMods, item.Title); if (existing != null) matchReason = "Title"; } if (existing != null) { diagSb.Append($" [Workshop#{idxInResults}] fileId={item.FileId.m_PublishedFileId} title=\"{item.Title}\" → 命中本地({matchReason}),合并元数据,跳过 Add\n"); // 把 Workshop 元数据合并到已存在的 Subscribed 记录上(保留 Source=Subscribed,让 UI 仍按本地视角处理) existing.SteamFileId = item.FileId; existing.SteamIsSubscribed = item.IsSubscribed; existing.SteamIsInstalled = item.IsInstalled; existing.SteamVotesUp = item.VotesUp; existing.SteamVotesDown = item.VotesDown; existing.SteamFileSize = (ulong)item.FileSize; existing.SteamCreatedTime = item.CreatedTime; existing.SteamUpdatedTime = item.UpdatedTime; // 本地 mod_info 里 author 可能空,工坊回来的 OwnerSteamId 能解析出 PersonaName 时回填 if (string.IsNullOrEmpty(existing.Author)) existing.Author = ResolveSteamPersonaName(item.OwnerSteamId); // Title/Description 不覆盖:本地 mod_info 的可能是玩家最新编辑过的,工坊页可能滞后 idxInResults++; continue; } diagSb.Append($" [Workshop#{idxInResults}] fileId={item.FileId.m_PublishedFileId} title=\"{item.Title}\" → 未命中本地,作为 Workshop 来源 Add\n"); _allMods.Add(new UIOutsideModListData { Source = ModListItemSource.Workshop, Title = item.Title, Author = ResolveSteamPersonaName(item.OwnerSteamId), Description = item.Description, TargetLanguage = MultilingualType.None, // 在线 Mod 在订阅安装前拿不到 mod_info,TargetLanguage 留空 SteamFileId = item.FileId, SteamIsSubscribed = item.IsSubscribed, SteamIsInstalled = item.IsInstalled, SteamVotesUp = item.VotesUp, SteamVotesDown = item.VotesDown, SteamFileSize = item.FileSize, SteamCreatedTime = item.CreatedTime, SteamUpdatedTime = item.UpdatedTime, LocalFolder = item.InstallFolder, }); idxInResults++; } } diagSb.Append($" --- 最终 _allMods.Count={_allMods.Count} ---"); Logic.CrashSight.LogSystem.LogInfo(diagSb.ToString()); } // 用 FileId 在已收集列表里找一条(默认值 0 当作"无 FileId",跳过匹配) private static UIOutsideModListData FindByFileId(List list, PublishedFileId_t fileId) { if (fileId.m_PublishedFileId == 0) return null; foreach (var m in list) { if (m.SteamFileId.m_PublishedFileId == fileId.m_PublishedFileId) return m; } return null; } // 用 Title 二次去重(本地导出 mod fileId=0,无法和 Steam 上传版本按 FileId 关联; // 而它们的 mod_info.title 完全一致,是同一个 mod 的两个副本,应合并) private static UIOutsideModListData FindByTitle(List list, string title) { if (string.IsNullOrEmpty(title)) return null; foreach (var m in list) { if (m.Title == title) return m; } return null; } // 把 Workshop mod 的 OwnerSteamId 解析为 PersonaName。 // 首次访问该用户时 Steam 客户端可能还没缓存名字,会返回空 / "[unknown]"; // RequestUserInformation 是异步后台拉取,下次 Query/重开界面就有了 —— 发售前不补回调刷新,足够用。 private static string ResolveSteamPersonaName(ulong ownerSteamId) { if (ownerSteamId == 0) return ""; try { var cSteamId = new CSteamID(ownerSteamId); // 第二参数 true = 只要昵称,不要 avatar;返回 false 说明本地没缓存,已经在后台拉 SteamFriends.RequestUserInformation(cSteamId, true); var name = SteamFriends.GetFriendPersonaName(cSteamId); if (string.IsNullOrEmpty(name) || name == "[unknown]") return ""; return name; } catch { return ""; } } private static void AppendLocalMods(List dst, List folders, ModListItemSource source) { foreach (var folder in folders) { var info = WorkshopModExporter.ReadModInfo(folder); if (info == null) continue; MultilingualType target = MultilingualType.None; if (Enum.TryParse(info.targetLanguage, true, out var parsed)) target = parsed; dst.Add(new UIOutsideModListData { Source = source, Title = string.IsNullOrEmpty(info.title) ? System.IO.Path.GetFileName(folder) : info.title, Author = info.author ?? "", Description = info.description ?? "", TargetLanguage = target, Version = info.version ?? "", LocalFolder = folder, }); } } // Subscribed 来源额外把 PublishedFileId 一并存入,方便和 Workshop 在线结果按 FileId 去重 private static void AppendSubscribedMods(List dst, List entries) { foreach (var entry in entries) { var info = WorkshopModExporter.ReadModInfo(entry.Folder); if (info == null) { Logic.CrashSight.LogSystem.LogWarning($"[UIOutsideMod] 订阅 mod 的 mod_info.json 读取失败,fileId={entry.FileId} folder={entry.Folder}"); continue; } MultilingualType target = MultilingualType.None; if (Enum.TryParse(info.targetLanguage, true, out var parsed)) target = parsed; // title 为空的"空选项"诊断:日志里能定位到具体哪个 fileId/folder if (string.IsNullOrEmpty(info.title)) { Logic.CrashSight.LogSystem.LogWarning($"[UIOutsideMod] 订阅 mod title 为空,回退显示文件夹名。fileId={entry.FileId} folder={entry.Folder}"); } dst.Add(new UIOutsideModListData { Source = ModListItemSource.Subscribed, Title = string.IsNullOrEmpty(info.title) ? System.IO.Path.GetFileName(entry.Folder) : info.title, Author = info.author ?? "", Description = info.description ?? "", TargetLanguage = target, Version = info.version ?? "", LocalFolder = entry.Folder, SteamFileId = entry.FileId, }); } } private static List FilterMods(List src, MultilingualType filter) { if (filter == MultilingualType.None) return new List(src); var result = new List(); foreach (var m in src) { if (m.TargetLanguage == filter) result.Add(m); } return result; } // 复用对象池:不够补足,多余隐藏 private void RenderList(List mods) { while (_itemPool.Count < mods.Count) { if (ListItemPrefab == null || ListContent == null) break; var go = Instantiate(ListItemPrefab, ListContent); var mono = go.GetComponent(); if (mono == null) { Destroy(go); break; } _itemPool.Add(mono); } for (int i = 0; i < _itemPool.Count; i++) { bool active = i < mods.Count; _itemPool[i].gameObject.SetActive(active); if (!active) continue; _itemPool[i].SetContent(mods[i]); _itemPool[i].OnSelected = OnListItemSelected; _itemPool[i].OnAddClicked = null; _itemPool[i].OnRemoveClicked = null; _itemPool[i].SetButtonVisibility(showAdd: false, showRemove: false); _itemPool[i].SetHighlight(_selected != null && ReferenceEquals(_selected, mods[i])); } } private void OnListItemSelected(UIOutsideModListData data) { _selected = data; RefreshDetail(); RefreshHighlight(); } private void RefreshHighlight() { foreach (var item in _itemPool) { if (!item.gameObject.activeSelf) continue; item.SetHighlight(_selected != null && ReferenceEquals(item.GetData(), _selected)); } } private void ClearSelection() { _selected = null; RefreshDetail(); } // ── 详情面板 ── private void RefreshDetail() { if (DetailRoot != null) DetailRoot.SetActive(_selected != null); if (_selected == null) return; if (TitleText != null) TitleText.text = _selected.Title; if (MainLanguageTagText != null) MainLanguageTagText.text = _selected.TargetLanguage.ToString(); if (AuthorText != null) AuthorText.text = _selected.Author; if (DescText != null) DescText.text = _selected.Description; if (TimeText != null) { if (_selected.SteamUpdatedTime > 0) { var dt = DateTimeOffset.FromUnixTimeSeconds(_selected.SteamUpdatedTime).LocalDateTime; TimeText.text = dt.ToString("yyyy-MM-dd HH:mm"); } else { TimeText.text = string.IsNullOrEmpty(_selected.Version) ? "" : $"v{_selected.Version}"; } } RefreshSubscribeUI(); // 切换条目时 TMP 文本长度不一,ContentSizeFitter / VerticalLayoutGroup 异步刷新会导致旧文本残影叠字。 // 这里同帧强制重建一遍 DetailRoot 下所有 layout,确保新文案立刻按新尺寸落位。 ForceRebuildDetailLayout(); } private void ForceRebuildDetailLayout() { if (DetailRoot == null) return; var root = DetailRoot.transform as RectTransform; if (root == null) return; // 先把所有子 LayoutGroup 重建一遍(从最深层往外,避免父级先重建子级尺寸还没定) var groups = DetailRoot.GetComponentsInChildren(true); for (int i = groups.Length - 1; i >= 0; i--) { var rt = groups[i].transform as RectTransform; if (rt != null) UnityEngine.UI.LayoutRebuilder.ForceRebuildLayoutImmediate(rt); } // 最后重建一次根,覆盖根上的 ContentSizeFitter UnityEngine.UI.LayoutRebuilder.ForceRebuildLayoutImmediate(root); } // 订阅状态 UI:3 个 hint 互斥 + 2 个按钮按状态显示 private void RefreshSubscribeUI() { bool hasSteamId = _selected != null && _selected.SteamFileId.m_PublishedFileId != 0; bool isSubscribed = _selected?.SteamIsSubscribed ?? false; bool isInstalled = _selected?.SteamIsInstalled ?? false; // 本地 Mod(非来自 Workshop 订阅)也算"已安装"展示 if (_selected != null && _selected.Source == ModListItemSource.Local) isInstalled = true; if (_selected != null && _selected.Source == ModListItemSource.Subscribed) { isSubscribed = true; isInstalled = true; } if (SubscribedHint != null) SubscribedHint.SetActive(isSubscribed && !isInstalled); if (InstalledHint != null) InstalledHint.SetActive(isInstalled); if (UnsubscribedHint != null) UnsubscribedHint.SetActive(hasSteamId && !isSubscribed && !isInstalled); // 仅 Workshop 来源的 mod 才有"订阅/取消订阅"操作(本地 mod 不能取消订阅) if (SubscribeButton != null) SubscribeButton.gameObject.SetActive(hasSteamId && !isSubscribed); if (UnsubscribeButton != null) UnsubscribeButton.gameObject.SetActive(hasSteamId && isSubscribed); } // ── 查询按钮 ── private void OnStartQueryClicked() { if (WorkshopModBrowser.Instance.IsQuerying) return; WorkshopModBrowser.Instance.QueryPage(1); UpdateQueryStatus(); } private void OnWorkshopQueryCompleted() { _hasQueriedWorkshop = true; RefreshList(); // 内部已联动 UpdateQueryStatus } private void UpdateQueryStatus() { if (QueryStatusText == null) return; var browser = WorkshopModBrowser.Instance; var assets = Table.Instance.TextDataAssets; if (browser.IsQuerying) { MultilingualManager.Instance.SetUIText(QueryStatusText, assets.OutsideModQueryInProgress); } else if (!_hasQueriedWorkshop) { MultilingualManager.Instance.SetUIText(QueryStatusText, assets.OutsideModQueryNotStarted); } else if (!string.IsNullOrEmpty(browser.LastError)) { // 错误信息直接覆盖(已是最易诊断的英文 Steam 返回,不走多语言) QueryStatusText.text = browser.LastError; } else { // 分子 = 列表当前过滤后实际可见数;分母 = Workshop 总条数 // 这样玩家切语言/筛选条件后状态文字直接反映"我现在看到了多少" MultilingualManager.Instance.SetUIText( QueryStatusText, assets.OutsideModQueryLoaded, new List { _visibleModCount.ToString(), browser.TotalResults.ToString() }); } } // ── 订阅/取消订阅 ── private void OnSubscribeClicked() { if (_selected == null || _selected.SteamFileId.m_PublishedFileId == 0) return; WorkshopModBrowser.Instance.Subscribe(_selected.SteamFileId); } private void OnUnsubscribeClicked() { if (_selected == null || _selected.SteamFileId.m_PublishedFileId == 0) return; WorkshopModBrowser.Instance.Unsubscribe(_selected.SteamFileId); } private void OnSteamSubscribeCompleted(PublishedFileId_t fileId, bool success) { if (!success) return; SyncSelectedFromBrowser(fileId); } private void OnSteamUnsubscribeCompleted(PublishedFileId_t fileId, bool success) { if (!success) return; SyncSelectedFromBrowser(fileId); } private void SyncSelectedFromBrowser(PublishedFileId_t fileId) { var item = WorkshopModBrowser.Instance.Results.Find(r => r.FileId == fileId); if (item != null && _selected != null && _selected.SteamFileId == fileId) { _selected.SteamIsSubscribed = item.IsSubscribed; _selected.SteamIsInstalled = item.IsInstalled; _selected.LocalFolder = item.InstallFolder; RefreshSubscribeUI(); } // 订阅状态变化会改变本地+订阅 mod 集合(已订阅 mod 会出现在 GetSubscribedModPaths 里), // 重拉一次列表让 ModPanel 与 QueryStatusText 都同步到最新状态 RefreshList(); } // ── SetLanguagePanel:当前语言下拉 ── // 当前语言下拉,改动 = 立即 ChangedMultilingual。如处于 Config 模式则隐式取消(丢弃副本) private void InitCurrentLanguageDropdown() { if (CurrentLanguageDropdown == null) return; CurrentLanguageDropdown.ClearOptions(); _languageOptions.Clear(); var options = new List(); foreach (MultilingualType t in Enum.GetValues(typeof(MultilingualType))) { if (t == MultilingualType.None || t == MultilingualType.Max) continue; _languageOptions.Add(t); options.Add(GetLanguageDisplayName(t)); } CurrentLanguageDropdown.AddOptions(options); SyncDropdownToCurrent(); CurrentLanguageDropdown.onValueChanged.RemoveAllListeners(); CurrentLanguageDropdown.onValueChanged.AddListener(OnCurrentLanguageChanged); } private void SyncDropdownToCurrent() { if (CurrentLanguageDropdown == null) return; var cur = MultilingualManager.Instance.CurrentType; int idx = _languageOptions.IndexOf(cur); if (idx < 0) idx = _languageOptions.IndexOf(MultilingualType.EN); if (idx < 0) idx = 0; CurrentLanguageDropdown.SetValueWithoutNotify(idx); // SetValueWithoutNotify 在新值 == 旧值时不会刷 caption,这里兜底直接写一次 if (CurrentLanguageDropdown.captionText != null && idx < _languageOptions.Count) { CurrentLanguageDropdown.captionText.text = GetLanguageDisplayName(_languageOptions[idx]); } } private void OnCurrentLanguageChanged(int i) { if (i < 0 || i >= _languageOptions.Count) return; // 切语言 = 隐式取消 Config 模式(按 PRD 要求) if (_isInConfigMode) ExitConfigMode(discardChanges: true); var lang = _languageOptions[i]; MultilingualManager.Instance.ChangedMultilingual(lang); // ConfigureLanguage 面板列出全部 41 种语言,玩家在此选的就是"我的偏好语言", // 无论是不是 5 主语言之一,都同步成 SecondaryLanguage。 // 这样下次玩家在 Setting 面板点 MoreLanguage 时能切回最近一次选过的语言, // 而不是一律退回默认 EN(之前只同步非主语言会导致玩家在此选日语后 MoreLanguage 仍为英语) ConfigManager.Instance.Config.SecondaryLanguage = lang; RefreshLanguagePanel(); } // ── SetLanguagePanel:刷新 ── // 入口:根据当前编辑语言(= 当前正在用的语言)刷新 Active 列表 + Available 列表 private void RefreshLanguagePanel() { _editingLanguage = MultilingualManager.Instance.CurrentType; if (_editingLanguage == MultilingualType.None) _editingLanguage = MultilingualType.EN; SyncDropdownToCurrent(); BuildAvailableMods(); LoadEditingModPathsFromConfig(); RenderActiveList(); RenderAvailableList(); } // 已安装 mod 缓存 = 本地 + 已订阅安装 private void BuildAvailableMods() { _availableMods.Clear(); AppendLocalMods(_availableMods, WorkshopModLoader.GetLocalModPaths(), ModListItemSource.Local); AppendSubscribedMods(_availableMods, WorkshopModLoader.GetSubscribedModEntries()); } // 把 Config 里当前编辑语言的 ModPaths 拷贝到 _editingModPaths(编辑副本) private void LoadEditingModPathsFromConfig() { _editingModPaths.Clear(); var config = ConfigManager.Instance.Config.ModLanguageConfigs.Find(c => c.Language == _editingLanguage); if (config != null && config.ModPaths != null) { _editingModPaths.AddRange(config.ModPaths); } } private static UIOutsideModListData CreateModData(string folder, ModListItemSource source) { var info = WorkshopModExporter.ReadModInfo(folder); if (info == null) return null; MultilingualType target = MultilingualType.None; if (Enum.TryParse(info.targetLanguage, true, out var parsed)) target = parsed; return new UIOutsideModListData { Source = source, Title = string.IsNullOrEmpty(info.title) ? System.IO.Path.GetFileName(folder) : info.title, Author = info.author ?? "", Description = info.description ?? "", TargetLanguage = target, Version = info.version ?? "", LocalFolder = folder, }; } // ── SetLanguagePanel:左列表(正在使用) ── // ActiveList = [_editingModPaths 反向构建] + [系统项作为最底部] // 顶部=高优先级=ModPaths 末尾,底部=系统项(最低优先级,永远存在) private void RenderActiveList() { // 收集需要展示的真实 mod 数据(Config 里有但磁盘上 mod 已被删除的,跳过) var realMods = new List(); for (int i = _editingModPaths.Count - 1; i >= 0; i--) { var folder = _editingModPaths[i]; var existing = _availableMods.Find(m => m.LocalFolder == folder); if (existing != null) { realMods.Add(existing); continue; } var data = CreateModData(folder, ModListItemSource.Local); if (data != null) realMods.Add(data); } // 总行数 = 真实 mod + 1 个系统项 int total = realMods.Count + 1; EnsurePoolSize(_activeItemPool, total, ActiveModListContent); for (int i = 0; i < _activeItemPool.Count; i++) { bool active = i < total; _activeItemPool[i].gameObject.SetActive(active); if (!active) continue; var item = _activeItemPool[i]; bool isSystem = i == total - 1; if (isSystem) { var (sysLang, sysTitle) = GetSystemFallbackFor(_editingLanguage); item.SetAsSystemItem(sysTitle, sysLang); } else { item.SetContent(realMods[i]); item.OnSelected = null; item.OnAddClicked = null; item.OnRemoveClicked = OnRemoveModClicked; // 删除按钮仅在 Config 模式下可见 item.SetButtonVisibility(showAdd: false, showRemove: _isInConfigMode); item.SetHighlight(false); } // 系统项确保按钮全隐藏(SetAsSystemItem 已经做了,这里冗余保护) } // LayoutGroup 排序:ScrollView 默认按 sibling index 渲染,pool 顺序就是显示顺序 // 让系统项始终在最后(即 pool[total-1] 在所有 active 项里 sibling index 最大) // 由于 pool 是顺序填的,且系统项放在 total-1,已经满足 } // 当前编辑语言的"系统项" = 5 主语言用自己默认;其他用 Default English private static (MultilingualType lang, string title) GetSystemFallbackFor(MultilingualType editing) { if (Array.IndexOf(PrimaryLanguages, editing) >= 0) { return (editing, $"Default {GetEnglishLanguageName(editing)}"); } return (MultilingualType.EN, "Default English"); } private static string GetEnglishLanguageName(MultilingualType t) { return t switch { MultilingualType.ZH => "Chinese", MultilingualType.TDZH => "Traditional Chinese", MultilingualType.EN => "English", MultilingualType.JP => "Japanese", MultilingualType.KR => "Korean", _ => t.ToString(), }; } // ── SetLanguagePanel:右列表(已安装) ── // 仅在 Config 模式下可见。CheckBox 不勾选时按 _editingLanguage 过滤;勾选时显示全部 private void RenderAvailableList() { if (!_isInConfigMode || InstalledModListContent == null) { // 非 Config 模式或字段未配置:隐藏整个右列表的所有 item for (int i = 0; i < _availableItemPool.Count; i++) { _availableItemPool[i].gameObject.SetActive(false); } return; } bool showAll = ShowAllToggle != null && ShowAllToggle.isOn; // 过滤 var filtered = new List(); foreach (var m in _availableMods) { if (showAll || m.TargetLanguage == _editingLanguage) filtered.Add(m); } EnsurePoolSize(_availableItemPool, filtered.Count, InstalledModListContent); // 已经在 _editingModPaths 里的 mod,把右侧 Add 按钮置灰 var activePaths = new HashSet(_editingModPaths); for (int i = 0; i < _availableItemPool.Count; i++) { bool active = i < filtered.Count; _availableItemPool[i].gameObject.SetActive(active); if (!active) continue; var item = _availableItemPool[i]; item.SetContent(filtered[i]); item.OnSelected = null; item.OnAddClicked = OnAddModClicked; item.OnRemoveClicked = null; item.SetButtonVisibility(showAdd: true, showRemove: false); item.SetAddInteractable(!activePaths.Contains(filtered[i].LocalFolder)); item.SetHighlight(false); } } private void EnsurePoolSize(List pool, int count, Transform parent) { if (parent == null || ListItemPrefab == null) return; while (pool.Count < count) { var go = Instantiate(ListItemPrefab, parent); var mono = go.GetComponent(); if (mono == null) { Destroy(go); break; } pool.Add(mono); } } // ── SetLanguagePanel:Config 模式 ── private void OnStartConfigClicked() { EnterConfigMode(); } private void OnSaveConfigClicked() { // 写回 Config,触发还原快照 + 按 Config 重 apply + TMP 重绘 ConfigManager.Instance.Config.SetModsForLanguage(_editingLanguage, _editingModPaths); MultilingualManager.Instance.SaveAndApplyMods(); ExitConfigMode(discardChanges: false); RefreshLanguagePanel(); } private void OnCancelConfigClicked() { ExitConfigMode(discardChanges: true); RefreshLanguagePanel(); } private void EnterConfigMode() { _isInConfigMode = true; LoadEditingModPathsFromConfig(); // 从 Config 拷贝一份新副本作为编辑起点 ApplyConfigModeUI(); RenderActiveList(); // 让左列表的 Remove 按钮亮起来 RenderAvailableList(); } private void ExitConfigMode(bool discardChanges) { _isInConfigMode = false; if (discardChanges) _editingModPaths.Clear(); ApplyConfigModeUI(); } private void ApplyConfigModeUI() { if (StartConfigButton != null) StartConfigButton.gameObject.SetActive(!_isInConfigMode); if (ConfigPart != null) ConfigPart.SetActive(_isInConfigMode); // 默认 ShowAllToggle 不勾选(仅显示当前语言匹配的 mod) if (_isInConfigMode && ShowAllToggle != null && ShowAllToggle.isOn) { ShowAllToggle.SetIsOnWithoutNotify(false); } } // ── SetLanguagePanel:增 / 删(仅作用于编辑副本 _editingModPaths) ── // 添加:插到 _editingModPaths 索引 0(最低优先级),UI 上落在左侧列表最下面(系统项之上) private void OnAddModClicked(UIOutsideModListData data) { if (!_isInConfigMode) return; if (data == null || string.IsNullOrEmpty(data.LocalFolder)) return; if (_editingModPaths.Contains(data.LocalFolder)) return; _editingModPaths.Insert(0, data.LocalFolder); RenderActiveList(); RenderAvailableList(); } // 删除:从 _editingModPaths 移除 private void OnRemoveModClicked(UIOutsideModListData data) { if (!_isInConfigMode) return; if (data == null || string.IsNullOrEmpty(data.LocalFolder)) return; if (!_editingModPaths.Remove(data.LocalFolder)) return; RenderActiveList(); RenderAvailableList(); } // 禁用 dropdown 的 captionText / itemText 上挂着的 MultilingualTextMono。 // 历史 prefab 上这俩 Label 自动绑定了占位 ID,切语言时 OnMultilingualChanged 会把 // 我们 AddOptions 写入的文字覆盖掉,看到下拉顶上显示的语言名错位。 private static void DisableDropdownLabelMultilingual(TMP_Dropdown dropdown) { if (dropdown == null) return; TrySetMultilingualBan(dropdown.captionText); TrySetMultilingualBan(dropdown.itemText); } private static void TrySetMultilingualBan(TMP_Text text) { if (text == null) return; var mono = text.GetComponent(); if (mono == null) return; mono.Ban = true; } // 第二语言显示名:与 UIOutsideMenuSettingPanelMono / UITopSettingView 保持一致 private static string GetLanguageDisplayName(MultilingualType type) { return type switch { MultilingualType.ZH => "简体中文", MultilingualType.TDZH => "繁體中文", MultilingualType.EN => "English", MultilingualType.JP => "日本語", MultilingualType.KR => "한국어", MultilingualType.RU => "Русский", MultilingualType.ES => "Español", MultilingualType.PT => "Português", MultilingualType.FR => "Français", MultilingualType.DE => "Deutsch", MultilingualType.ID => "Bahasa Indonesia", MultilingualType.TH => "ภาษาไทย", MultilingualType.PL => "Polski", MultilingualType.VI => "Tiếng Việt", MultilingualType.MS => "Bahasa Melayu", MultilingualType.UK => "Українська", MultilingualType.KZ => "Қазақша", MultilingualType.TR => "Türkçe", MultilingualType.IT => "Italiano", MultilingualType.NL => "Nederlands", MultilingualType.FI => "Suomi", MultilingualType.SV => "Svenska", MultilingualType.NO => "Norsk", MultilingualType.CS => "Čeština", MultilingualType.HU => "Magyar", MultilingualType.EL => "Ελληνικά", MultilingualType.RO => "Română", MultilingualType.ET => "Eesti", MultilingualType.LT => "Lietuvių", MultilingualType.HR => "Hrvatski", MultilingualType.SR => "Српски", MultilingualType.SL => "Slovenščina", MultilingualType.SK => "Slovenčina", MultilingualType.BE => "Беларуская", MultilingualType.HE => "עברית", MultilingualType.BG => "Български", MultilingualType.UZ => "Oʻzbekcha", MultilingualType.KY => "Кыргызча", MultilingualType.MN => "Монгол", MultilingualType.AR => "العربية", MultilingualType.DA => "Dansk", MultilingualType.TL => "Filipino", MultilingualType.Custom => "Custom", _ => type.ToString() }; } // ── MakeModePanel:导出模板 ── // 初始化两个语言下拉。默认目标=当前语言(玩家最常见用例:给自己的语言导个模板继续翻译/校对) // 默认参考=ZH(最完整的源语言,便于翻译者参考原意) private void InitExportLanguageDropdowns() { _exportLanguageOptions.Clear(); var options = new List(); foreach (MultilingualType t in Enum.GetValues(typeof(MultilingualType))) { if (t == MultilingualType.None || t == MultilingualType.Max) continue; _exportLanguageOptions.Add(t); options.Add(GetLanguageDisplayName(t)); } if (ExportTargetLanguageDropdown != null) { ExportTargetLanguageDropdown.ClearOptions(); ExportTargetLanguageDropdown.AddOptions(options); int idx = _exportLanguageOptions.IndexOf(MultilingualManager.Instance.CurrentType); if (idx < 0) idx = _exportLanguageOptions.IndexOf(MultilingualType.EN); if (idx < 0) idx = 0; ExportTargetLanguageDropdown.SetValueWithoutNotify(idx); } if (ExportReferenceLanguageDropdown != null) { ExportReferenceLanguageDropdown.ClearOptions(); ExportReferenceLanguageDropdown.AddOptions(options); int idx = _exportLanguageOptions.IndexOf(MultilingualType.ZH); if (idx < 0) idx = 0; ExportReferenceLanguageDropdown.SetValueWithoutNotify(idx); } } // ExportCore:核心文案(跳过 IsSecondary 标记的次要文案) private void OnExportCoreClicked() { StopAllCoroutines(); StartCoroutine(CoExport(coreOnly: true)); } // ExportFull:全量导出(仅跳过 IsDeprecated) // 用 coroutine 把"导出中→实际导出→导出成功"分两帧,让玩家看得到 InProgress 状态 private void OnExportFullClicked() { StopAllCoroutines(); StartCoroutine(CoExport(coreOnly: false)); } private System.Collections.IEnumerator CoExport(bool coreOnly) { // 选用对应的状态文本(Core/Full 各显示在自己旁边) var statusText = coreOnly ? ExportCoreStatusText : ExportFullStatusText; // 先显示"导出中",禁用两个按钮防止并发/重复点 if (ExportFullButton != null) ExportFullButton.interactable = false; if (ExportCoreButton != null) ExportCoreButton.interactable = false; SetExportStatus(statusText, Table.Instance.TextDataAssets.OutsideModExportInProgress, success: false, useMultilingual: true); // 让 UI 渲染一帧(否则同步导出完成后 InProgress 文字根本来不及显示) yield return null; yield return null; var (target, reference, modName) = ReadExportParams(); if (target == MultilingualType.None || target == MultilingualType.Max || reference == MultilingualType.None || reference == MultilingualType.Max) { SetExportStatus(statusText, "目标语言或参考语言无效", success: false, useMultilingual: false); } else { var path = WorkshopModExporter.ExportModTemplate(target, reference, modName, coreOnly); if (string.IsNullOrEmpty(path)) { SetExportStatus(statusText, "导出失败,请查看 Console 日志", success: false, useMultilingual: false); } else { _lastExportPath = path; // 走多语言模板,{param} = 路径 var paramList = new List { path }; if (statusText != null) { MultilingualManager.Instance.SetUIText( statusText, Table.Instance.TextDataAssets.OutsideModExportSucceeded, paramList); statusText.gameObject.SetActive(true); } if (ExportOpenFolderButton != null) ExportOpenFolderButton.gameObject.SetActive(true); // 导出成功后自动刷新上传子模块的"本地 mod 文件夹"下拉,让玩家立即能看到新导出的 mod RefreshUploadFolderDropdown(); } } if (ExportFullButton != null) ExportFullButton.interactable = true; if (ExportCoreButton != null) ExportCoreButton.interactable = true; } private (MultilingualType target, MultilingualType reference, string modName) ReadExportParams() { MultilingualType target = MultilingualType.EN; MultilingualType reference = MultilingualType.ZH; if (ExportTargetLanguageDropdown != null) { int i = ExportTargetLanguageDropdown.value; if (i >= 0 && i < _exportLanguageOptions.Count) target = _exportLanguageOptions[i]; } if (ExportReferenceLanguageDropdown != null) { int i = ExportReferenceLanguageDropdown.value; if (i >= 0 && i < _exportLanguageOptions.Count) reference = _exportLanguageOptions[i]; } string modName = ExportModNameInput != null ? ExportModNameInput.text?.Trim() : null; if (string.IsNullOrEmpty(modName)) modName = null; // 让 Exporter 自动生成 TranslationMod__<时间戳> return (target, reference, modName); } // 在文件浏览器中打开最近一次导出目录 private void OnExportOpenFolderClicked() { if (string.IsNullOrEmpty(_lastExportPath)) return; if (!System.IO.Directory.Exists(_lastExportPath)) { // 目录已不存在:把错误显示在 Full 状态文本上(目前只有 Full 会写 _lastExportPath) SetExportStatus(ExportFullStatusText, "目录已不存在", success: false, useMultilingual: false); return; } Application.OpenURL("file:///" + _lastExportPath.Replace('\\', '/')); } private void ResetExportStatus() { _lastExportPath = null; // 进面板默认隐藏两个状态文本,避免占位 if (ExportCoreStatusText != null) ExportCoreStatusText.gameObject.SetActive(false); if (ExportFullStatusText != null) ExportFullStatusText.gameObject.SetActive(false); if (ExportOpenFolderButton != null) ExportOpenFolderButton.gameObject.SetActive(false); } // 设置某个按钮的状态文本。 // useMultilingual=true 时把 msg 视作多语言 ID(用 SetUIText 渲染);false 视作裸字符串 // 调试/错误信息走裸字符串;正式状态走多语言 // 调用此方法时会自动 SetActive(true),让默认隐藏的文本节点出现 private void SetExportStatus(TextMeshProUGUI target, string msg, bool success, bool useMultilingual) { if (target == null) return; target.gameObject.SetActive(true); if (useMultilingual) { MultilingualManager.Instance.SetUIText(target, msg); } else { // 强制清掉 MultilingualTextMono 的 ID,否则下一次 ChangedMultilingual 会把裸字符串覆盖回多语言文本 var mono = target.GetComponent(); if (mono != null) mono.ID = 0; target.text = msg; } } // ── MakeModePanel:上传到 Workshop ── // 进入 panel 时初始化上传模块(扫本地 mod 文件夹 + 设默认 toggle 状态) private void InitUploadModule() { // 接管 Content 下编辑器里残留的 prefab 实例:纳入 pool 并隐藏。 // 否则 QueryUserMods 异步返回前,这些"未初始化"占位行会直接露给玩家。 AdoptResidualUploadedItems(); RefreshUploadFolderDropdown(); // 默认勾"创建新" if (UploadCreateNewToggle != null) { UploadCreateNewToggle.SetIsOnWithoutNotify(true); } if (UploadUpdateExistingToggle != null) { UploadUpdateExistingToggle.SetIsOnWithoutNotify(false); } _uploadModeIsUpdate = false; ApplyUploadModeUI(); if (UploadStatusText != null) UploadStatusText.text = ""; if (UploadOpenInSteamButton != null) UploadOpenInSteamButton.gameObject.SetActive(false); if (UploadAgreementHint != null) UploadAgreementHint.SetActive(false); // 兜底隐藏"正在刷新中"的查询提示,避免切到 MakeMode tab 时残留上次的文字/状态 if (UploadedModListStatusText != null) { UploadedModListStatusText.text = ""; UploadedModListStatusText.gameObject.SetActive(false); } RefreshUploadButtonInteractable(); } // 扫 WorkshopMods/ 列出所有含 mod_info.json 的子目录 private void RefreshUploadFolderDropdown() { _uploadFolderOptions.Clear(); if (UploadModFolderDropdown == null) return; UploadModFolderDropdown.ClearOptions(); var options = new List(); foreach (var folder in WorkshopModLoader.GetLocalModPaths()) { _uploadFolderOptions.Add(folder); options.Add(System.IO.Path.GetFileName(folder)); } if (options.Count == 0) { options.Add("(无可上传的本地 Mod)"); } UploadModFolderDropdown.AddOptions(options); UploadModFolderDropdown.SetValueWithoutNotify(0); UploadModFolderDropdown.onValueChanged.RemoveAllListeners(); UploadModFolderDropdown.onValueChanged.AddListener(_ => OnUploadFolderChanged()); OnUploadFolderChanged(); } // 选了某个本地 mod 文件夹后,自动从 mod_info.json 填充 标题/描述 + 检测预览图 private void OnUploadFolderChanged() { var folder = GetSelectedUploadFolder(); if (string.IsNullOrEmpty(folder)) { if (UploadTitleInput != null) UploadTitleInput.text = ""; if (UploadDescriptionInput != null) UploadDescriptionInput.text = ""; if (UploadPreviewStatusText != null) { MultilingualManager.Instance.SetUIText(UploadPreviewStatusText, Table.Instance.TextDataAssets.OutsideModUploadPreviewNotProvided); } RefreshUploadButtonInteractable(); return; } var info = WorkshopModExporter.ReadModInfo(folder); if (UploadTitleInput != null) { UploadTitleInput.text = info != null ? (info.title ?? "") : System.IO.Path.GetFileName(folder); } if (UploadDescriptionInput != null) { UploadDescriptionInput.text = info != null ? (info.description ?? "") : ""; } var previewPath = System.IO.Path.Combine(folder, WorkshopModExporter.PreviewFileName); bool hasPreview = System.IO.File.Exists(previewPath); if (UploadPreviewStatusText != null) { if (hasPreview) { // {param} = preview 文件名(让模板里可以写 "Detected: {param}" 等) var paramList = new List { WorkshopModExporter.PreviewFileName }; MultilingualManager.Instance.SetUIText(UploadPreviewStatusText, Table.Instance.TextDataAssets.OutsideModUploadPreviewDetected, paramList); } else { MultilingualManager.Instance.SetUIText(UploadPreviewStatusText, Table.Instance.TextDataAssets.OutsideModUploadPreviewNotProvided); } } // 根据 mod_info.publishedFileId 自动切 Create/Update 模式 // 玩家换设备后只要带着 mod 文件夹,第一次选中就能看到正确的"更新已有"目标 TryAutoSelectUploadMode(info); RefreshUploadButtonInteractable(); } /// /// 按 mod_info.publishedFileId 自动切换上传模式 + 选中对应 FileId /// /// /// 触发场景:选了一个本地 mod 文件夹 / UserMods 异步查询完成。 /// 若 publishedFileId=0 → 保持当前模式(默认 Create) /// 若 publishedFileId 在 _uploadUpdateTargets 里 → 切到 Update 并选中 /// 若 publishedFileId 不在 UserMods 里(别人的 mod 或 UserMods 还没加载完)→ 不强切,保持现状 /// private void TryAutoSelectUploadMode(WorkshopModInfo info) { if (info == null || info.publishedFileId <= 0) return; if (UploadCreateNewToggle == null || UploadUpdateExistingToggle == null) return; ulong targetId = (ulong)info.publishedFileId; int matchIndex = -1; for (int i = 0; i < _uploadUpdateTargets.Count; i++) { if (_uploadUpdateTargets[i].m_PublishedFileId == targetId) { matchIndex = i; break; } } if (matchIndex < 0) return; // 不在我自己上传过的列表里,先不动模式 // 切到 Update 模式(不触发 onValueChanged 避免循环回调) UploadCreateNewToggle.SetIsOnWithoutNotify(false); UploadUpdateExistingToggle.SetIsOnWithoutNotify(true); _uploadModeIsUpdate = true; ApplyUploadModeUI(); if (UploadUpdateTargetDropdown != null) { UploadUpdateTargetDropdown.SetValueWithoutNotify(matchIndex); } } private string GetSelectedUploadFolder() { if (UploadModFolderDropdown == null) return null; int i = UploadModFolderDropdown.value; if (i < 0 || i >= _uploadFolderOptions.Count) return null; return _uploadFolderOptions[i]; } // 创建新/更新已有 切换:单选互斥(无论 prefab 是否绑 ToggleGroup 都正确) // 规则:1) 任意时刻只有一个 on 2) 玩家点已 on 的那个不会让两个都 off(保证总有一个 on) private void OnUploadModeToggleChanged(bool _) { if (UploadCreateNewToggle == null || UploadUpdateExistingToggle == null) return; bool createOn = UploadCreateNewToggle.isOn; bool updateOn = UploadUpdateExistingToggle.isOn; if (createOn && updateOn) { // 两个都 on:刚刚被点亮的那个保留,关掉原来 on 的那个。 // _uploadModeIsUpdate 记录的是事件触发前的状态 → 旧的 on 即此值,关掉它。 if (_uploadModeIsUpdate) { UploadUpdateExistingToggle.SetIsOnWithoutNotify(false); } else { UploadCreateNewToggle.SetIsOnWithoutNotify(false); } } else if (!createOn && !updateOn) { // 两个都 off:玩家点掉了当前 on 的那个 → 把它重新打开,禁止"什么都不选" if (_uploadModeIsUpdate) { UploadUpdateExistingToggle.SetIsOnWithoutNotify(true); } else { UploadCreateNewToggle.SetIsOnWithoutNotify(true); } } // 同步缓存 _uploadModeIsUpdate = UploadUpdateExistingToggle.isOn; ApplyUploadModeUI(); RefreshUploadButtonInteractable(); } private void ApplyUploadModeUI() { bool isUpdate = UploadUpdateExistingToggle != null && UploadUpdateExistingToggle.isOn; if (UploadUpdateTargetDropdown != null) { UploadUpdateTargetDropdown.gameObject.SetActive(isUpdate); } } // 上传按钮可点性:必须有选中的本地 mod;如果是 update 模式还得有选中的目标 fileId private void RefreshUploadButtonInteractable() { if (UploadStartButton == null) return; bool busy = WorkshopModUploader.Instance.IsBusy; bool hasFolder = !string.IsNullOrEmpty(GetSelectedUploadFolder()); bool isUpdate = UploadUpdateExistingToggle != null && UploadUpdateExistingToggle.isOn; bool hasUpdateTarget = !isUpdate || (_uploadUpdateTargets.Count > 0 && UploadUpdateTargetDropdown != null && UploadUpdateTargetDropdown.value >= 0 && UploadUpdateTargetDropdown.value < _uploadUpdateTargets.Count); UploadStartButton.interactable = !busy && hasFolder && hasUpdateTarget; } // 用户已上传列表查询完成 → 同时刷新右侧 Update 目标下拉 private void OnUserModsQueryCompleted() { RefreshUploadUpdateTargetDropdown(); RefreshUploadedModList(); // 列表刚到位,重新按当前选中文件夹的 publishedFileId 尝试自动切 Update 模式 // 首次 OnUploadFolderChanged 时 _uploadUpdateTargets 还为空,必须这里补一次 var folder = GetSelectedUploadFolder(); if (!string.IsNullOrEmpty(folder)) { TryAutoSelectUploadMode(WorkshopModExporter.ReadModInfo(folder)); } RefreshUploadButtonInteractable(); } private void RefreshUploadUpdateTargetDropdown() { _uploadUpdateTargets.Clear(); if (UploadUpdateTargetDropdown == null) return; UploadUpdateTargetDropdown.ClearOptions(); var options = new List(); foreach (var item in WorkshopModBrowser.Instance.UserMods) { _uploadUpdateTargets.Add(item.FileId); options.Add($"{item.Title} ({item.FileId.m_PublishedFileId})"); } if (options.Count == 0) { options.Add("(暂无已上传的 Mod)"); } UploadUpdateTargetDropdown.AddOptions(options); UploadUpdateTargetDropdown.SetValueWithoutNotify(0); UploadUpdateTargetDropdown.onValueChanged.RemoveAllListeners(); UploadUpdateTargetDropdown.onValueChanged.AddListener(_ => RefreshUploadButtonInteractable()); } // 点击"上传"按钮:根据 toggle 走 CreateAndUpload 或 UpdateMod private void OnUploadStartClicked() { var folder = GetSelectedUploadFolder(); if (string.IsNullOrEmpty(folder)) return; var title = UploadTitleInput != null ? UploadTitleInput.text?.Trim() : ""; var description = UploadDescriptionInput != null ? UploadDescriptionInput.text ?? "" : ""; var previewPath = System.IO.Path.Combine(folder, WorkshopModExporter.PreviewFileName); if (!System.IO.File.Exists(previewPath)) previewPath = null; if (UploadAgreementHint != null) UploadAgreementHint.SetActive(false); if (UploadOpenInSteamButton != null) UploadOpenInSteamButton.gameObject.SetActive(false); _lastUploadedFileId = default; bool isUpdate = UploadUpdateExistingToggle != null && UploadUpdateExistingToggle.isOn; if (isUpdate) { int i = UploadUpdateTargetDropdown != null ? UploadUpdateTargetDropdown.value : -1; if (i < 0 || i >= _uploadUpdateTargets.Count) return; var fileId = _uploadUpdateTargets[i]; // 走 update 路径。把 UI 上的 title/description 一并传下去, // Uploader 内部会回写本地 mod_info.json 并调 SetItemTitle/SetItemDescription if (UploadStatusText != null) UploadStatusText.text = "正在更新..."; WorkshopModUploader.Instance.UpdateMod(fileId, folder, "Update from in-game", success => { OnUploadFinished(success, fileId); }, title, description); } else { if (UploadStatusText != null) UploadStatusText.text = "正在上传..."; WorkshopModUploader.Instance.CreateAndUploadMod(folder, title, description, previewPath, (success, fileId) => { OnUploadFinished(success, fileId); }); } RefreshUploadButtonInteractable(); } private void OnUploadFinished(bool success, PublishedFileId_t fileId) { if (UploadStatusText != null) UploadStatusText.text = WorkshopModUploader.Instance.StatusMessage; if (success) { _lastUploadedFileId = fileId; if (UploadOpenInSteamButton != null) UploadOpenInSteamButton.gameObject.SetActive(true); // 上传成功后刷新已上传列表,同时下拉的"更新目标"也会被刷新 QueryUserModsAndRefresh(); } else { // 检查是否是协议未签 var msg = WorkshopModUploader.Instance.StatusMessage ?? ""; if (msg.Contains("需要同意协议=True") || msg.Contains("k_EResultAccessDenied")) { if (UploadAgreementHint != null) UploadAgreementHint.SetActive(true); } } RefreshUploadButtonInteractable(); } private void OnUploadOpenInSteamClicked() { if (_lastUploadedFileId.m_PublishedFileId == 0) return; Application.OpenURL($"https://steamcommunity.com/sharedfiles/filedetails/?id={_lastUploadedFileId.m_PublishedFileId}"); } private void OnUploadAgreementOpenClicked() { Application.OpenURL("https://steamcommunity.com/workshop/workshoplegalagreement/"); } // ── MakeModePanel:我已上传的 Mod 列表 ── // 静默查询:OnOpen / 上传成功后调用,不写状态文字(避免 MakeMode tab 一打开就糊一行"正在查询...") private void QueryUserModsAndRefresh() { Logic.CrashSight.LogSystem.LogInfo("[UIOutsideMod] QueryUserMods 已发起"); WorkshopModBrowser.Instance.QueryUserMods(); } private void OnUploadedModRefreshClicked() { Logic.CrashSight.LogSystem.LogInfo("[UIOutsideMod] 玩家点击刷新已上传列表"); if (UploadedModListStatusText != null) { UploadedModListStatusText.gameObject.SetActive(true); UploadedModListStatusText.text = "正在查询..."; } QueryUserModsAndRefresh(); } // 把 UploadedModListContent 下编辑器里残留的子物体纳入 pool。 // 带 UIOutsideUploadedModListItemMono 的复用进 pool;其它(如纯占位)一律隐藏。 // 仅在 pool 为空(即首次进入面板)时执行,避免重复 SetContent 时把 pool 顺序打乱 private void AdoptResidualUploadedItems() { if (UploadedModListContent == null) return; if (_uploadedItemPool.Count > 0) return; for (int i = 0; i < UploadedModListContent.childCount; i++) { var child = UploadedModListContent.GetChild(i); var mono = child.GetComponent(); if (mono != null) { _uploadedItemPool.Add(mono); } child.gameObject.SetActive(false); } } private void RefreshUploadedModList() { var browser = WorkshopModBrowser.Instance; // 只有玩家主动点过刷新(文字组件被激活)时才更新状态文字;OnOpen 的静默查询保持隐藏 if (UploadedModListStatusText != null && UploadedModListStatusText.gameObject.activeSelf) { if (!string.IsNullOrEmpty(browser.UserQueryError)) { UploadedModListStatusText.text = browser.UserQueryError; } else { UploadedModListStatusText.text = $"共 {browser.UserMods.Count} 项"; } } if (UploadedModListContent == null || UploadedModListItemPrefab == null) return; // 池足量 while (_uploadedItemPool.Count < browser.UserMods.Count) { var go = Instantiate(UploadedModListItemPrefab, UploadedModListContent); var mono = go.GetComponent(); if (mono == null) { Destroy(go); break; } _uploadedItemPool.Add(mono); } for (int i = 0; i < _uploadedItemPool.Count; i++) { bool active = i < browser.UserMods.Count; _uploadedItemPool[i].gameObject.SetActive(active); if (!active) continue; var mod = browser.UserMods[i]; _uploadedItemPool[i].SetContent(mod.FileId, mod.Title); _uploadedItemPool[i].OnOpenInSteam = OnUploadedItemOpenInSteam; } } private void OnUploadedItemOpenInSteam(PublishedFileId_t fileId) { Application.OpenURL($"https://steamcommunity.com/sharedfiles/filedetails/?id={fileId.m_PublishedFileId}"); } } }