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}");
}
}
}