TH1/Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideModView.cs
2026-05-14 02:41:49 +08:00

1842 lines
67 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
{
/// <summary>
/// Mod 管理界面mainOption 切换 SetLanguagePanel / ModPanel。
/// 本期主要实现 ModPanel左侧列表 + 右侧详情,支持按 MultilingualType 筛选。
/// </summary>
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<UIOutsideModListData> _allMods = new List<UIOutsideModListData>();
private readonly List<UIOutsideModListItemMono> _itemPool = new List<UIOutsideModListItemMono>();
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<string> _editingModPaths = new List<string>();
// "已安装 mod" 数据:本地 + 已订阅安装。供 Active 列表查 LocalFolder 复用
private readonly List<UIOutsideModListData> _availableMods = new List<UIOutsideModListData>();
private readonly List<UIOutsideModListItemMono> _activeItemPool = new List<UIOutsideModListItemMono>();
private readonly List<UIOutsideModListItemMono> _availableItemPool = new List<UIOutsideModListItemMono>();
// CurrentLanguageDropdown 的索引到 MultilingualType 的映射
private readonly List<MultilingualType> _languageOptions = new List<MultilingualType>();
// MakeModPanel 两个 Dropdown 的索引到 MultilingualType 的映射(与 _languageOptions 内容相同,但分开持有避免耦合)
private readonly List<MultilingualType> _exportLanguageOptions = new List<MultilingualType>();
// 上传子模块的内部数据
private readonly List<string> _uploadFolderOptions = new List<string>(); // dropdown 索引 → 文件夹绝对路径
private readonly List<PublishedFileId_t> _uploadUpdateTargets = new List<PublishedFileId_t>(); // dropdown 索引 → fileId
private PublishedFileId_t _lastUploadedFileId;
private readonly List<UIOutsideUploadedModListItemMono> _uploadedItemPool = new List<UIOutsideUploadedModListItemMono>();
// 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<string> { "All" };
var values = new List<MultilingualType> { 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_infoTargetLanguage 留空
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<UIOutsideModListData> 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<UIOutsideModListData> 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<UIOutsideModListData> dst, List<string> folders, ModListItemSource source)
{
foreach (var folder in folders)
{
var info = WorkshopModExporter.ReadModInfo(folder);
if (info == null) continue;
MultilingualType target = MultilingualType.None;
if (Enum.TryParse<MultilingualType>(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<UIOutsideModListData> dst, List<WorkshopModLoader.SubscribedModEntry> 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<MultilingualType>(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<UIOutsideModListData> FilterMods(List<UIOutsideModListData> src, MultilingualType filter)
{
if (filter == MultilingualType.None) return new List<UIOutsideModListData>(src);
var result = new List<UIOutsideModListData>();
foreach (var m in src)
{
if (m.TargetLanguage == filter) result.Add(m);
}
return result;
}
// 复用对象池:不够补足,多余隐藏
private void RenderList(List<UIOutsideModListData> mods)
{
while (_itemPool.Count < mods.Count)
{
if (ListItemPrefab == null || ListContent == null) break;
var go = Instantiate(ListItemPrefab, ListContent);
var mono = go.GetComponent<UIOutsideModListItemMono>();
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<UnityEngine.UI.LayoutGroup>(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);
}
// 订阅状态 UI3 个 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<string> { _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<string>();
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<MultilingualType>(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<UIOutsideModListData>();
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<UIOutsideModListData>();
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<string>(_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<UIOutsideModListItemMono> pool, int count, Transform parent)
{
if (parent == null || ListItemPrefab == null) return;
while (pool.Count < count)
{
var go = Instantiate(ListItemPrefab, parent);
var mono = go.GetComponent<UIOutsideModListItemMono>();
if (mono == null) { Destroy(go); break; }
pool.Add(mono);
}
}
// ── SetLanguagePanelConfig 模式 ──
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<MultilingualTextMono>();
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<string>();
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<string> { 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_<lang>_<时间戳>
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<Logic.Multilingual.MultilingualTextMono>();
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<string>();
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<string> { 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();
}
/// <summary>
/// 按 mod_info.publishedFileId 自动切换上传模式 + 选中对应 FileId
/// </summary>
/// <remarks>
/// 触发场景:选了一个本地 mod 文件夹 / UserMods 异步查询完成。
/// 若 publishedFileId=0 → 保持当前模式(默认 Create
/// 若 publishedFileId 在 _uploadUpdateTargets 里 → 切到 Update 并选中
/// 若 publishedFileId 不在 UserMods 里(别人的 mod 或 UserMods 还没加载完)→ 不强切,保持现状
/// </remarks>
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<string>();
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<UIOutsideUploadedModListItemMono>();
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<UIOutsideUploadedModListItemMono>();
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}");
}
}
}