diff --git a/MD/Workshop_Multilingual_Design.md b/MD/Workshop_Multilingual_Design.md index c1eebf27d..69a0e7ba4 100644 --- a/MD/Workshop_Multilingual_Design.md +++ b/MD/Workshop_Multilingual_Design.md @@ -4,10 +4,11 @@ 基于 TH1 现有的多语言系统架构,本创意工坊方案的设计核心为**“现有语种的局部文本替换”**。为了保证系统的稳定性和玩家创作的低门槛,明确以下设计边界: -1. **仅替换,不新增**:多语言 Mod 仅用于修改和润色游戏中已存在的语言(如 ZH, EN, JP 等),不支持通过 Mod 新增全新的语言枚举。 -2. **无需处理字体**:因为不新增语种,所有 Mod 的显示将直接复用游戏本体中该语言对应的原有字体(TMP_FontAsset)以及排版配置,创作者完全不需要考虑字体渲染和缺失问题。 +1. **仅替换,不新增**:多语言 Mod 仅用于修改和润色游戏中已存在的语言(如 ZH, EN, JP 等),不支持通过 Mod 新增全新的语言枚举。但系统提供了一个特殊的 **Custom**(自定义)语种,专供 Mod 应用时使用——它不参与游戏本体的多语言流程,只作为 Mod 目标语言的一个额外选项。 +2. **无需处理字体**:因为不新增语种,所有 Mod 的显示将直接复用游戏本体中该语言对应的原有字体(TMP_FontAsset)以及排版配置,创作者完全不需要考虑字体渲染和缺失问题。Custom 语种回退到 EN 字体。 3. **支持增量/部分替换**:Mod 允许只修改一部分文本。玩家不需要翻译游戏内的所有内容,翻译了哪几条,游戏运行时就仅替换那几条,未修改的内容保持游戏默认文本。 -4. **多 Mod 冲突处理**:当玩家同时订阅了多个针对同一语种的翻译 Mod 时,系统将按照读取顺序依次进行替换,后读取的 Mod 翻译会覆盖先读取的 Mod 翻译(按序替换机制)。 +4. **多 Mod 优先级处理**:玩家可以为每个语种单独配置一组 Mod 列表及其优先级。低优先级 Mod 先应用,高优先级 Mod 后应用(覆盖低优先级)。Mod 本身不绑定语种——任意 Mod 可以被分配给任意语种。此配置存储在 `GameConfig.ModLanguageConfigs` 中,由 UI 或编辑器面板负责设置。 +5. **Mod 与语种解耦**:导出模板时,玩家可以分别指定"目标语种"(Mod 最终要修改的语种)和"参考语种"(CSV 中显示的参照文本列)。mod_info 记录创作者声明的目标语种,但在应用阶段可以被玩家的优先级配置覆盖。 --- @@ -16,11 +17,24 @@ 对于想要参与多语言优化的创作者,整体流程分为三个简单的步骤,所有操作均设计为“零门槛”。 ### 步骤一:一键获取 Mod 源文件 -玩家无需寻找游戏目录或解包游戏,直接在游戏运行时的客户端内(例如“主菜单”或“Mod 菜单”中)点击**“获取翻译模板”**按钮。 -系统会自动在玩家的电脑本地生成一个标准化的 Mod 源文件文件夹,里面包含了游戏中当前所有可翻译内容的对照表,以及必要的 Mod 配置文件。 +玩家无需寻找游戏目录或解包游戏,在游戏客户端内点击**"获取翻译模板"**按钮,并指定**目标语种**(要修改的语言)和**参考语种**(CSV 中显示的参照列)。 +系统会自动在本地生成标准化的 Mod 源文件文件夹,包含所有可翻译内容的对照表及 Mod 配置文件。 ### 步骤二:本地编辑翻译内容 -玩家打开生成的本地文件夹,找到对应的翻译表格文件(例如 CSV 格式)。 +玩家打开生成的本地文件夹,找到对应的翻译表格文件(CSV 格式)。 +表格共 4 列: +* **ID**:游戏内多语言系统分配的唯一标识符(不可修改)。 +* **EN**:英文文本,作为翻译标准参考。 +* **{参考语种}**:玩家指定的参考语言列,供对照(修改无效)。 +* **Translation**:创作者填写的翻译内容。**只有此列有内容的行才会在游戏中生效替换;留空则保持原文。** + +玩家只需使用 Excel 或任意文本编辑器,将润色后的文本填入 Translation 列即可。 + +### 步骤三:上传至创意工坊 +玩家完成本地编辑,并在文件夹内准备好一张预览图(封面图)后,回到游戏客户端中。 +点击**"上传 Mod"**按钮,在游戏内 UI 中选择刚刚编辑好的文件夹,填写好 Mod 的名称和简介,即可一键打包并上传到 Steam 创意工坊,供其他玩家订阅。“主菜单”或“Mod 菜单”中)点击**“获取翻译模板”**按钮。 + +### 步骤三:上传至创意工坊 表格中会清晰地列出每条文本的**“唯一 ID”**、**“原文参考”**以及留空的**“翻译内容列”**。 玩家只需要使用 Excel 或任意文本编辑器,将自己润色或修改后的文本填入“翻译内容列”即可。不想修改的条目直接留空,系统会自动忽略。 @@ -44,13 +58,24 @@ 一个标准的多语言 Mod 文件夹在本地包含以下三个核心文件: 1. **mod_info 配置** - 用于声明该 Mod 的基础元数据,主要包括:Mod 的标题、作者,以及**最核心的:该 Mod 目标替换的语种类型**(必须为游戏中已存在的语种枚举名称,如 EN)。 - -2. **translation 翻译数据表** - 这是 Mod 的核心数据载体。表结构包含三列: - * **ID**:游戏内多语言系统分配的唯一标识符(不可修改)。 - * **原文参考**:游戏当前版本该 ID 对应的官方文本(供玩家参考,修改无效)。 - * **翻译内容**:创作者填写的全新文本。只有此列有内容的行,才会在游戏中生效替换。 + 用于声明该 Mod 的基础元数据,主要包括:Mod 的标题、作者,以及该 Mod 创作者声明的目标替换语种(如 EN、ZH、Custom 等)。注意:在应用阶段,玩家可通过优先级配置将任意 Mod 用于任意语种,覆盖此声明。 + +2. **translation 翻译数据表(CSV 格式,4 列)** + 这是 Mod 的核心数据载体。表结构如下: + + | 列名 | 说明 | + |--------------|----------------------------------------------------| + | ID | 游戏内唯一标识符(不可修改) | + | EN | 英文原文,作为翻译标准参考(修改无效) | + | {参考语种} | 玩家导出时指定的参考语言原文(修改无效) | + | Translation | 创作者填写的翻译内容。**非空才生效**,空则跳过 | + + 示例(目标 ZH,参考 EN): + ``` + ID,EN,EN,Translation + item_sword_name,Iron Sword,Iron Sword,铁剑 + item_shield_name,Wooden Shield,Wooden Shield, + ``` 3. **preview 封面图** 一张用于展示在 Steam 创意工坊列表中的预览图片。 @@ -59,16 +84,54 @@ ## 5. 运行时读取与覆盖流程 -为了让 Mod 生效,游戏在启动时的底层数据处理逻辑将遵循以下顺序: +``` +游戏启动 + ↓ +MultilingualManager.Init() + → 加载官方多语言数据(Resources/Export/Multilingual) + ↓ +ApplyWorkshopMods() + → 读取 GameConfig.ModLanguageConfigs(玩家的语种-Mod 优先级配置) + ↓ +WorkshopModLoader.ApplyModsWithConfig(data, configs) + 对每个语种配置: + 按列表顺序(低→高优先级)依次应用各 Mod: + 读取 mod_info.json → 确定目标语种(优先使用配置覆盖,其次用 mod_info 声明) + 读取 translation.csv → 仅处理 Translation 列非空的行 + 在内存中覆盖对应 ID 的目标语种字段 + ↓ +ChangedMultilingual(currentType) → 刷新所有 UI 文本 +``` -1. **基础数据加载**:游戏启动,多语言管理器首先读取原生的多语言数据,完成官方默认多语言字典的初始化。 -2. **获取订阅列表**:通过 Steamworks API,获取当前玩家已订阅且下载完毕的所有多语言 Mod 文件夹的本地路径。 -3. **数据按序解析与覆盖**: - * 遍历这些 Mod 文件夹,读取其中的 `mod_info` 配置文件,确认其目标替换的语种。 - * 读取翻译数据表,按行解析。 - * 如果该行的“翻译内容”不为空,则在内存中找到对应 ID 的多语言数据节点。 - * 强制将该节点下对应的目标语种字段(例如 `EN` 字段)的值替换为玩家提供的新文本。 -4. **UI 渲染与刷新**:数据覆盖完成后,触发系统的 UI 文本刷新机制。所有 UI 组件将读取内存中最新的字典数据进行展示。 +**关于嵌套引用的兼容性:**“翻译内容”不为空,则在内存中找到对应 ID 的多语言数据节点。 +**关于嵌套引用的兼容性:** +原系统的 `****` 专有名词嵌套引用机制与 Mod 系统完全兼容。只要玩家在 Translation 列保留了 `****` 格式,运行时底层仍会正常触发解析,无需任何特殊处理。 -**关于嵌套引用的特殊说明:** -原系统中的 `****` 专有名词嵌套引用机制在 Mod 系统中完美兼容。只要创作者在填写的翻译内容中保留了这种富文本语法格式,运行时底层在获取该字符串时,仍会正常触发原有的解析逻辑,自动将其替换为带颜色和图标的动态内容,无需进行任何特殊处理。 +--- + +## 6. Workshop 浏览器(编辑器功能) + +由于游戏未正式发售导致创意工坊页面无法直接在 Steam 客户端访问,编辑器内提供了 **Workshop 浏览器标签页**(⑤ 工坊浏览): + +- **查询**:分页查询当前 AppId 对应创意工坊中的所有 Mod(通过 `SteamUGC.CreateQueryAllUGCRequest`)。 +- **订阅/取消订阅**:直接在编辑器内操作,Steam 客户端自动处理下载/删除。 +- **状态显示**:显示每个 Mod 的订阅状态、安装状态、本地路径、标签、投票数等信息。 +- **驱动机制**:编辑器通过 `EditorApplication.update` 定期调用 `SteamAPI.RunCallbacks()` 驱动 Steamworks 异步回调,无需运行游戏。 + +核心类:`Logic.Multilingual.WorkshopModBrowser`(单例) + +--- + +## 7. 编辑器测试面板(WorkshopModEditorWindow) + +菜单路径:`TH1工具 → 创意工坊多语言 Mod 测试面板` + +| 标签页 | 功能说明 | +|---------------|------------------------------------------------------------------------| +| ① 导出模板 | 指定目标语言 + 参考语言,导出 4 列 CSV 模板(Translation 列留空) | +| ② 上传Workshop| 选择 Mod 文件夹上传到 Steam 创意工坊 | +| ③ Mod优先级 | 模拟为每个语种分配 Mod 并调整优先级(▲▼排序、添加/移除) | +| ④ 应用Mod | 按优先级配置或批量应用本地/订阅 Mod,支持目标语言覆盖 | +| ⑤ 工坊浏览 | 查询当前 AppId 所有 Workshop Mod,支持订阅/取消订阅 | +| ⑥ CSV测试 | 4 列 CSV 读写回环测试(含特殊字符验证) | +| ⑦ 数据检视 | 查看多语言数据概览、按 ID 查询、检视 Mod 文件夹内容(4 列格式) | diff --git a/Unity/Assets/Editor/WorkshopModEditorWindow.cs b/Unity/Assets/Editor/WorkshopModEditorWindow.cs index 829ee1616..f3ee575b0 100644 --- a/Unity/Assets/Editor/WorkshopModEditorWindow.cs +++ b/Unity/Assets/Editor/WorkshopModEditorWindow.cs @@ -2,7 +2,7 @@ * @Author: 白哉 * @Description: 创意工坊多语言 Mod 编辑器测试窗口(全功能测试面板) * @Date: 2026年04月14日 星期二 15:43:02 - * @Modify: + * @Modify: 2026年04月16日 - 4列CSV/Custom语种/Mod优先级配置/Workshop浏览器 */ using System; @@ -10,45 +10,64 @@ using System.Collections.Generic; using System.IO; using System.Text; using Logic.Multilingual; +using Steamworks; +using TH1_Logic.Config; using UnityEditor; using UnityEngine; public class WorkshopModEditorWindow : EditorWindow { // ── 选项卡 ── - private enum Tab { Export, Upload, Load, CsvTest, Inspect } + private enum Tab { Export, Upload, ModConfig, Apply, Browse, CsvTest, Inspect } private Tab _currentTab = Tab.Export; - private readonly string[] _tabNames = { "① 导出模板", "② 上传Workshop", "③ 加载Mod", "④ CSV测试", "⑤ 数据检视" }; + private readonly string[] _tabNames = + { "① 导出模板", "② 上传Workshop", "③ Mod优先级", "④ 应用Mod", "⑤ 工坊浏览", "⑥ CSV测试", "⑦ 数据检视" }; // ── 导出面板状态 ── - private MultilingualType _exportLanguage = MultilingualType.EN; - private string _exportModName = ""; + private MultilingualType _exportTargetLanguage = MultilingualType.EN; // 目标语言(mod_info 写入,决定替换哪个语种) + private MultilingualType _exportRefLanguage = MultilingualType.ZH; // 参考语言(CSV 第3列供玩家参考) + private string _exportModName = ""; private string _lastExportPath = ""; // ── 上传面板状态 ── - private string _uploadFolder = ""; - private string _uploadTitle = ""; + private string _uploadFolder = ""; + private string _uploadTitle = ""; private string _uploadDescription = ""; private string _uploadPreviewPath = ""; - private string _uploadStatus = ""; + private string _uploadStatus = ""; - // ── 加载面板状态 ── + // ── Mod 优先级配置面板状态 ── + private MultilingualType _configSelectedLang = MultilingualType.EN; + private List _allAvailableMods = new List(); // 所有可用 Mod 路径 + private Vector2 _configModListScroll; + private Vector2 _configAvailScroll; + private string _configStatus = ""; + private GameConfig _editorConfig; // 编辑器内独立配置,模拟运行时配置 + + // ── 应用 Mod 面板状态 ── private List _localModPaths = new List(); - private string _loadStatus = ""; - private Vector2 _loadScroll; + private string _loadStatus = ""; + private Vector2 _loadScroll; + private MultilingualType _applyTargetOverride = MultilingualType.None; + + // ── Workshop 浏览器状态 ── + private Vector2 _browseScroll; + private string _browseStatus = ""; + private bool _browseSteamOk; // ── CSV 测试状态 ── - private string _csvTestInput = ""; - private string _csvTestOutput = ""; + private MultilingualType _csvTestRefLang = MultilingualType.ZH; + private string _csvTestInput = ""; + private string _csvTestOutput = ""; private Vector2 _csvInputScroll; private Vector2 _csvOutputScroll; // ── 数据检视状态 ── - private string _inspectFolder = ""; - private string _inspectResult = ""; + private string _inspectFolder = ""; + private string _inspectResult = ""; private Vector2 _inspectScroll; - private uint _inspectSearchId; - private string _inspectSearchResult = ""; + private uint _inspectSearchId; + private string _inspectSearchResult = ""; // ── 通用 ── private Vector2 _mainScroll; @@ -57,23 +76,54 @@ public class WorkshopModEditorWindow : EditorWindow public static void ShowWindow() { var window = GetWindow("Workshop Mod 测试"); - window.minSize = new Vector2(620, 480); + window.minSize = new Vector2(680, 520); + } + + private void OnEnable() + { + _editorConfig = new GameConfig(); + // 注册 Workshop 浏览器事件 + WorkshopModBrowser.Instance.OnQueryCompleted += OnBrowseQueryCompleted; + WorkshopModBrowser.Instance.OnSubscribeCompleted += OnBrowseSubscribeCompleted; + WorkshopModBrowser.Instance.OnUnsubscribeCompleted+= OnBrowseUnsubscribeCompleted; + // 注册 Editor 心跳,用于驱动 SteamAPI 回调 + EditorApplication.update += EditorUpdate; + } + + private void OnDisable() + { + WorkshopModBrowser.Instance.OnQueryCompleted -= OnBrowseQueryCompleted; + WorkshopModBrowser.Instance.OnSubscribeCompleted -= OnBrowseSubscribeCompleted; + WorkshopModBrowser.Instance.OnUnsubscribeCompleted-= OnBrowseUnsubscribeCompleted; + EditorApplication.update -= EditorUpdate; + } + + private void EditorUpdate() + { + // 仅在 Workshop 浏览标签页时驱动回调,避免不必要开销 + if (_currentTab == Tab.Browse) + { + WorkshopModBrowser.Instance.RunCallbacks(); + if (WorkshopModBrowser.Instance.IsQuerying || WorkshopModBrowser.Instance.IsSubscribeOperating) + Repaint(); + } } private void OnGUI() { - // 顶部选项卡 _currentTab = (Tab)GUILayout.Toolbar((int)_currentTab, _tabNames, GUILayout.Height(28)); EditorGUILayout.Space(4); _mainScroll = EditorGUILayout.BeginScrollView(_mainScroll); switch (_currentTab) { - case Tab.Export: DrawExportTab(); break; - case Tab.Upload: DrawUploadTab(); break; - case Tab.Load: DrawLoadTab(); break; - case Tab.CsvTest: DrawCsvTestTab(); break; - case Tab.Inspect: DrawInspectTab(); break; + case Tab.Export: DrawExportTab(); break; + case Tab.Upload: DrawUploadTab(); break; + case Tab.ModConfig:DrawModConfigTab(); break; + case Tab.Apply: DrawApplyTab(); break; + case Tab.Browse: DrawBrowseTab(); break; + case Tab.CsvTest: DrawCsvTestTab(); break; + case Tab.Inspect: DrawInspectTab(); break; } EditorGUILayout.EndScrollView(); } @@ -85,29 +135,31 @@ public class WorkshopModEditorWindow : EditorWindow { EditorGUILayout.LabelField("导出 Mod 翻译模板", EditorStyles.boldLabel); EditorGUILayout.HelpBox( - "按选定语言导出标准 Mod 文件夹:mod_info.json + translation.csv\n" + - "CSV 格式:ID | 中文原文(ZH) | 目标语言文本", + "导出标准 Mod 文件夹:mod_info.json + translation.csv(4列格式)\n" + + "CSV 列:ID | EN(英文标准) | 参考语言文本 | Translation(玩家填写,留空不替换)\n" + + "目标语言:决定此 Mod 修改哪个语种字段(写入 mod_info)\n" + + "参考语言:CSV 第3列内容,辅助玩家翻译时参考", MessageType.Info); EditorGUILayout.Space(4); - _exportLanguage = (MultilingualType)EditorGUILayout.EnumPopup("目标语言", _exportLanguage); - _exportModName = EditorGUILayout.TextField("Mod 名称(留空自动生成)", _exportModName); + _exportTargetLanguage = (MultilingualType)EditorGUILayout.EnumPopup("目标语言(mod_info)", _exportTargetLanguage); + _exportRefLanguage = (MultilingualType)EditorGUILayout.EnumPopup("参考语言(CSV第3列)", _exportRefLanguage); + _exportModName = EditorGUILayout.TextField("Mod 名称(留空自动生成)", _exportModName); EditorGUILayout.Space(4); - - // 显示导出根目录 var rootPath = WorkshopModExporter.GetModRootPath(); EditorGUILayout.LabelField("导出根目录", rootPath, EditorStyles.wordWrappedLabel); EditorGUILayout.Space(8); - using (new EditorGUI.DisabledScope( - _exportLanguage == MultilingualType.None || _exportLanguage == MultilingualType.Max)) + bool exportInvalid = _exportTargetLanguage == MultilingualType.None || + _exportTargetLanguage == MultilingualType.Max || + _exportRefLanguage == MultilingualType.None || + _exportRefLanguage == MultilingualType.Max; + using (new EditorGUI.DisabledScope(exportInvalid)) { if (GUILayout.Button("导出 Mod 模板", GUILayout.Height(32))) - { DoExport(); - } } if (!string.IsNullOrEmpty(_lastExportPath)) @@ -115,15 +167,12 @@ public class WorkshopModEditorWindow : EditorWindow EditorGUILayout.Space(4); EditorGUILayout.HelpBox($"✅ 导出成功:{_lastExportPath}", MessageType.None); if (GUILayout.Button("在文件浏览器中打开")) - { EditorUtility.RevealInFinder(_lastExportPath); - } } } private void DoExport() { - // 在编辑器中模拟设置当前语言 var data = Resources.Load("Export/Multilingual"); if (data == null) { @@ -132,9 +181,8 @@ public class WorkshopModEditorWindow : EditorWindow } data.RefreshDict(); - var targetType = _exportLanguage; var modName = string.IsNullOrEmpty(_exportModName) - ? $"TranslationMod_{targetType}_{DateTime.Now:yyyyMMdd_HHmmss}" + ? $"TranslationMod_{_exportTargetLanguage}_{DateTime.Now:yyyyMMdd_HHmmss}" : _exportModName; try @@ -142,7 +190,7 @@ public class WorkshopModEditorWindow : EditorWindow var modPath = Path.Combine(WorkshopModExporter.GetModRootPath(), modName); Directory.CreateDirectory(modPath); - // 构建翻译条目 + // 构建翻译条目:EN为翻译标准列,_exportRefLanguage为参考列,Translation留空 var entries = new List(); int deprecatedCount = 0; foreach (var item in data.Items) @@ -150,35 +198,36 @@ public class WorkshopModEditorWindow : EditorWindow if (item.IsDeprecated) { deprecatedCount++; continue; } entries.Add(new TranslationEntry { - Id = item.ID, - Reference = item.ZH ?? "", - Translation = item.GetStrByType(targetType) ?? "" + Id = item.ID, + EnglishText = item.EN ?? "", + ReferenceText = item.GetStrByType(_exportRefLanguage) ?? "", + Translation = "" // 留空,由玩家填写 }); } - // 写 mod_info.json + // 写 mod_info.json(targetLanguage = 玩家指定的目标语言) var modInfo = new WorkshopModInfo { - title = $"{targetType} Translation Mod", - author = "EditorTest", - targetLanguage = targetType.ToString(), - description = $"Exported from editor at {DateTime.Now}", - version = "1.0" + title = $"{_exportTargetLanguage} Translation Mod", + author = "EditorTest", + targetLanguage = _exportTargetLanguage.ToString(), + description = $"Exported from editor at {DateTime.Now}", + version = "1.0" }; File.WriteAllText( Path.Combine(modPath, WorkshopModExporter.ModInfoFileName), JsonUtility.ToJson(modInfo, true), new UTF8Encoding(true)); - // 写 translation.csv - var csv = WorkshopModCsv.WriteCsv(targetType, entries); + // 写 translation.csv(4列格式) + var csv = WorkshopModCsv.WriteCsv(_exportRefLanguage, entries); File.WriteAllText( Path.Combine(modPath, WorkshopModExporter.TranslationFileName), csv, new UTF8Encoding(true)); _lastExportPath = modPath; - Debug.Log($"✅ Mod 模板导出完成:{modPath},共 {entries.Count} 条 (跳过已弃用 {deprecatedCount} 条)"); + Debug.Log($"✅ Mod 模板导出完成:{modPath},目标={_exportTargetLanguage},参考={_exportRefLanguage},共 {entries.Count} 条 (跳过已弃用 {deprecatedCount} 条)"); } catch (Exception e) { @@ -200,7 +249,6 @@ public class WorkshopModEditorWindow : EditorWindow EditorGUILayout.Space(4); - // 文件夹选择 EditorGUILayout.BeginHorizontal(); _uploadFolder = EditorGUILayout.TextField("Mod 文件夹", _uploadFolder); if (GUILayout.Button("浏览...", GUILayout.Width(60))) @@ -211,29 +259,25 @@ public class WorkshopModEditorWindow : EditorWindow } EditorGUILayout.EndHorizontal(); - // 验证文件夹 bool folderValid = !string.IsNullOrEmpty(_uploadFolder) && Directory.Exists(_uploadFolder); - bool hasModInfo = folderValid && - File.Exists(Path.Combine(_uploadFolder, WorkshopModExporter.ModInfoFileName)); - bool hasCsv = folderValid && - File.Exists(Path.Combine(_uploadFolder, WorkshopModExporter.TranslationFileName)); + bool hasModInfo = folderValid && File.Exists(Path.Combine(_uploadFolder, WorkshopModExporter.ModInfoFileName)); + bool hasCsv = folderValid && File.Exists(Path.Combine(_uploadFolder, WorkshopModExporter.TranslationFileName)); if (folderValid) { - EditorGUILayout.LabelField(" mod_info.json", hasModInfo ? "✅ 存在" : "❌ 缺失"); - EditorGUILayout.LabelField(" translation.csv", hasCsv ? "✅ 存在" : "❌ 缺失"); + EditorGUILayout.LabelField(" mod_info.json", hasModInfo ? "✅ 存在" : "❌ 缺失"); + EditorGUILayout.LabelField(" translation.csv", hasCsv ? "✅ 存在" : "❌ 缺失"); } EditorGUILayout.Space(4); - _uploadTitle = EditorGUILayout.TextField("标题", _uploadTitle); - _uploadDescription = EditorGUILayout.TextField("描述", _uploadDescription); + _uploadTitle = EditorGUILayout.TextField("标题", _uploadTitle); + _uploadDescription = EditorGUILayout.TextField("描述", _uploadDescription); EditorGUILayout.BeginHorizontal(); _uploadPreviewPath = EditorGUILayout.TextField("预览图路径", _uploadPreviewPath); if (GUILayout.Button("浏览...", GUILayout.Width(60))) { - var selected = EditorUtility.OpenFilePanel("选择预览图", - _uploadFolder ?? "", "png,jpg,jpeg"); + var selected = EditorUtility.OpenFilePanel("选择预览图", _uploadFolder ?? "", "png,jpg,jpeg"); if (!string.IsNullOrEmpty(selected)) _uploadPreviewPath = selected; } EditorGUILayout.EndHorizontal(); @@ -244,9 +288,7 @@ public class WorkshopModEditorWindow : EditorWindow using (new EditorGUI.DisabledScope(!hasModInfo || !hasCsv || isBusy)) { if (GUILayout.Button(isBusy ? "上传中..." : "创建并上传到 Workshop", GUILayout.Height(32))) - { DoUpload(); - } } if (!string.IsNullOrEmpty(_uploadStatus)) @@ -263,22 +305,156 @@ public class WorkshopModEditorWindow : EditorWindow _uploadFolder, _uploadTitle, _uploadDescription, _uploadPreviewPath, (success, fileId) => { - if (success) - _uploadStatus = $"✅ 上传成功!PublishedFileId = {fileId}"; - else - _uploadStatus = "❌ 上传失败,请查看 Console 日志"; + _uploadStatus = success + ? $"✅ 上传成功!PublishedFileId = {fileId}" + : "❌ 上传失败,请查看 Console 日志"; Repaint(); }); } // ═══════════════════════════════════════════════════════════ - // ③ 加载/应用 Mod + // ③ Mod 优先级配置(模拟运行时配置,UI 侧留接口) // ═══════════════════════════════════════════════════════════ - private void DrawLoadTab() + private void DrawModConfigTab() + { + EditorGUILayout.LabelField("Mod 优先级配置", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "为每个语种指定使用哪些 Mod,并设置优先级顺序。\n" + + "列表越靠下的 Mod 优先级越高(最后应用,可覆盖上方的 Mod)。\n" + + "此处的配置模拟运行时 GameConfig.ModLanguageConfigs 的行为。", + MessageType.Info); + + EditorGUILayout.Space(4); + + // 语种选择 + _configSelectedLang = (MultilingualType)EditorGUILayout.EnumPopup("当前配置语种", _configSelectedLang); + + EditorGUILayout.Space(4); + + // 扫描所有可用 Mod + if (GUILayout.Button("🔍 扫描所有可用 Mod(本地 + 订阅)")) + { + _allAvailableMods = WorkshopModLoader.GetAllAvailableModPaths(); + _configStatus = $"扫描完成,共 {_allAvailableMods.Count} 个 Mod"; + } + + EditorGUILayout.Space(4); + + var config = _editorConfig.GetOrCreateModConfig(_configSelectedLang); + var modPaths = config.ModPaths; + + // ─ 左:已配置的 Mod 优先级列表 ─ + EditorGUILayout.LabelField($"「{_configSelectedLang}」 已启用的 Mod(低→高优先级)", EditorStyles.boldLabel); + _configModListScroll = EditorGUILayout.BeginScrollView(_configModListScroll, GUILayout.MaxHeight(200)); + for (int i = 0; i < modPaths.Count; i++) + { + var path = modPaths[i]; + string infoStr = GetModInfoStr(path); + + EditorGUILayout.BeginHorizontal("box"); + EditorGUILayout.LabelField($"[{i}] {Path.GetFileName(path)}{infoStr}", EditorStyles.wordWrappedLabel); + + using (new EditorGUI.DisabledScope(i == 0)) + { + if (GUILayout.Button("▲", GUILayout.Width(24))) + { + _editorConfig.MoveModPriority(_configSelectedLang, i, i - 1); + _configStatus = "已调整优先级"; + } + } + using (new EditorGUI.DisabledScope(i == modPaths.Count - 1)) + { + if (GUILayout.Button("▼", GUILayout.Width(24))) + { + _editorConfig.MoveModPriority(_configSelectedLang, i, i + 1); + _configStatus = "已调整优先级"; + } + } + if (GUILayout.Button("移除", GUILayout.Width(48))) + { + _editorConfig.RemoveModFromLanguage(_configSelectedLang, path); + _configStatus = $"已从「{_configSelectedLang}」移除 {Path.GetFileName(path)}"; + break; + } + EditorGUILayout.EndHorizontal(); + } + EditorGUILayout.EndScrollView(); + + // ─ 右:可用 Mod 列表(点击添加) ─ + if (_allAvailableMods.Count > 0) + { + EditorGUILayout.Space(4); + EditorGUILayout.LabelField("可用 Mod(点击添加到当前语种)", EditorStyles.boldLabel); + _configAvailScroll = EditorGUILayout.BeginScrollView(_configAvailScroll, GUILayout.MaxHeight(160)); + foreach (var path in _allAvailableMods) + { + bool alreadyAdded = modPaths.Contains(path); + string infoStr = GetModInfoStr(path); + + EditorGUILayout.BeginHorizontal("box"); + EditorGUILayout.LabelField( + $"{(alreadyAdded ? "✅" : " ")} {Path.GetFileName(path)}{infoStr}", + EditorStyles.wordWrappedLabel); + using (new EditorGUI.DisabledScope(alreadyAdded)) + { + if (GUILayout.Button("添加", GUILayout.Width(48))) + { + _editorConfig.AddModToLanguage(_configSelectedLang, path); + _configStatus = $"已将 {Path.GetFileName(path)} 添加到「{_configSelectedLang}」"; + } + } + EditorGUILayout.EndHorizontal(); + } + EditorGUILayout.EndScrollView(); + } + + EditorGUILayout.Space(4); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button($"清空「{_configSelectedLang}」的所有 Mod 配置")) + { + _editorConfig.ClearModsForLanguage(_configSelectedLang); + _configStatus = $"已清空「{_configSelectedLang}」配置"; + } + EditorGUILayout.EndHorizontal(); + + if (!string.IsNullOrEmpty(_configStatus)) + { + EditorGUILayout.Space(4); + EditorGUILayout.HelpBox(_configStatus, MessageType.None); + } + + // 显示所有语种的配置摘要 + EditorGUILayout.Space(8); + EditorGUILayout.LabelField("所有语种配置摘要", EditorStyles.boldLabel); + foreach (var c in _editorConfig.ModLanguageConfigs) + { + if (c.ModPaths.Count == 0) continue; + EditorGUILayout.LabelField($" {c.Language}: {c.ModPaths.Count} 个 Mod", EditorStyles.wordWrappedLabel); + } + } + + private string GetModInfoStr(string path) + { + var infoPath = Path.Combine(path, WorkshopModExporter.ModInfoFileName); + if (!File.Exists(infoPath)) return ""; + try + { + var info = JsonUtility.FromJson(File.ReadAllText(infoPath)); + return $" [{info.targetLanguage}] {info.title}"; + } + catch { return ""; } + } + + // ═══════════════════════════════════════════════════════════ + // ④ 应用 Mod + // ═══════════════════════════════════════════════════════════ + private void DrawApplyTab() { EditorGUILayout.LabelField("加载并应用多语言 Mod", EditorStyles.boldLabel); EditorGUILayout.HelpBox( "扫描本地 WorkshopMods 目录下的 Mod,逐个或批量应用到内存中的多语言数据。\n" + + "「按优先级配置应用」使用③中配置的语种-Mod 优先级顺序。\n" + "应用后可通过「数据检视」查看覆盖结果。", MessageType.Info); @@ -287,8 +463,6 @@ public class WorkshopModEditorWindow : EditorWindow EditorStyles.wordWrappedLabel); EditorGUILayout.Space(4); - - // 扫描按钮 if (GUILayout.Button("🔍 扫描本地 Mod 文件夹")) { _localModPaths = WorkshopModLoader.GetLocalModPaths(); @@ -297,38 +471,25 @@ public class WorkshopModEditorWindow : EditorWindow EditorGUILayout.Space(4); - // Mod 列表 + // 单个应用:支持目标语言覆盖 if (_localModPaths.Count > 0) { - _loadScroll = EditorGUILayout.BeginScrollView(_loadScroll, GUILayout.MaxHeight(240)); + EditorGUILayout.LabelField("目标语言覆盖(None = 使用 mod_info 中声明的语言)"); + _applyTargetOverride = (MultilingualType)EditorGUILayout.EnumPopup("目标语言覆盖", _applyTargetOverride); + + _loadScroll = EditorGUILayout.BeginScrollView(_loadScroll, GUILayout.MaxHeight(220)); for (int i = 0; i < _localModPaths.Count; i++) { - var path = _localModPaths[i]; + var path = _localModPaths[i]; var dirName = Path.GetFileName(path); - - // 尝试读取 mod_info - string infoStr = ""; - var infoPath = Path.Combine(path, WorkshopModExporter.ModInfoFileName); - if (File.Exists(infoPath)) - { - try - { - var info = JsonUtility.FromJson(File.ReadAllText(infoPath)); - infoStr = $" [{info.targetLanguage}] {info.title}"; - } - catch { /* ignore */ } - } + string infoStr = GetModInfoStr(path); EditorGUILayout.BeginHorizontal("box"); EditorGUILayout.LabelField($"{i + 1}. {dirName}{infoStr}", EditorStyles.wordWrappedLabel); if (GUILayout.Button("应用", GUILayout.Width(50))) - { - DoApplySingleMod(path); - } + DoApplySingleMod(path, _applyTargetOverride); if (GUILayout.Button("打开", GUILayout.Width(50))) - { EditorUtility.RevealInFinder(path); - } EditorGUILayout.EndHorizontal(); } EditorGUILayout.EndScrollView(); @@ -336,16 +497,13 @@ public class WorkshopModEditorWindow : EditorWindow EditorGUILayout.Space(8); - // 批量操作 EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("按优先级配置应用", GUILayout.Height(28))) + DoApplyByConfig(); if (GUILayout.Button("应用所有本地 Mod", GUILayout.Height(28))) - { DoApplyAllLocalMods(); - } - if (GUILayout.Button("应用所有 Mod (本地+订阅)", GUILayout.Height(28))) - { + if (GUILayout.Button("应用所有(本地+订阅)", GUILayout.Height(28))) DoApplyAllMods(); - } EditorGUILayout.EndHorizontal(); if (!string.IsNullOrEmpty(_loadStatus)) @@ -355,87 +513,240 @@ public class WorkshopModEditorWindow : EditorWindow } } - private void DoApplySingleMod(string path) + private MultilingualData LoadMultilingualData(out string error) { + error = ""; var data = Resources.Load("Export/Multilingual"); - if (data == null) - { - _loadStatus = "❌ 无法加载 Export/Multilingual"; - return; - } - int count = WorkshopModLoader.ApplyModFromFolder(data, path); - _loadStatus = $"✅ 已应用 {Path.GetFileName(path)},覆盖 {count} 条翻译"; + if (data == null) error = "❌ 无法加载 Export/Multilingual"; + return data; + } + + private void DoApplySingleMod(string path, MultilingualType targetOverride = MultilingualType.None) + { + var data = LoadMultilingualData(out string err); + if (data == null) { _loadStatus = err; return; } + int count = WorkshopModLoader.ApplyModFromFolder(data, path, targetOverride); + string langStr = targetOverride != MultilingualType.None ? $"(目标覆盖={targetOverride})" : ""; + _loadStatus = $"✅ 已应用 {Path.GetFileName(path)}{langStr},覆盖 {count} 条翻译"; + } + + private void DoApplyByConfig() + { + var data = LoadMultilingualData(out string err); + if (data == null) { _loadStatus = err; return; } + int count = WorkshopModLoader.ApplyModsWithConfig(data, _editorConfig.ModLanguageConfigs); + _loadStatus = $"✅ 按优先级配置应用完成,共覆盖 {count} 条翻译"; } private void DoApplyAllLocalMods() { - var data = Resources.Load("Export/Multilingual"); - if (data == null) - { - _loadStatus = "❌ 无法加载 Export/Multilingual"; - return; - } + var data = LoadMultilingualData(out string err); + if (data == null) { _loadStatus = err; return; } int count = WorkshopModLoader.ApplyLocalMods(data); _loadStatus = $"✅ 已应用所有本地 Mod,共覆盖 {count} 条翻译"; } private void DoApplyAllMods() { - var data = Resources.Load("Export/Multilingual"); - if (data == null) - { - _loadStatus = "❌ 无法加载 Export/Multilingual"; - return; - } + var data = LoadMultilingualData(out string err); + if (data == null) { _loadStatus = err; return; } int count = WorkshopModLoader.ApplyAllMods(data); _loadStatus = $"✅ 已应用所有 Mod (本地+订阅),共覆盖 {count} 条翻译"; } // ═══════════════════════════════════════════════════════════ - // ④ CSV 读写测试 + // ⑤ Workshop 浏览器 // ═══════════════════════════════════════════════════════════ - private void DrawCsvTestTab() + private void DrawBrowseTab() { - EditorGUILayout.LabelField("CSV 读写回环测试", EditorStyles.boldLabel); + EditorGUILayout.LabelField("Steam 创意工坊浏览器", EditorStyles.boldLabel); EditorGUILayout.HelpBox( - "测试 WorkshopModCsv 的 WriteCsv / ReadCsv 方法。\n" + - "可手动输入 CSV 内容测试解析,也可自动生成测试数据验证回环。", + "查询当前 AppId 下的所有 Workshop Mod,可在编辑器中直接订阅/取消订阅。\n" + + "需要 Steam 客户端正在运行且已登录。已订阅且已安装的 Mod 可在④中加载。", MessageType.Info); EditorGUILayout.Space(4); - // 自动测试 - if (GUILayout.Button("🧪 运行自动回环测试(含特殊字符)", GUILayout.Height(28))) + // 检测 Steam 状态 + bool steamOk = false; + try { steamOk = SteamAPI.IsSteamRunning(); } catch { } + _browseSteamOk = steamOk; + + EditorGUILayout.LabelField("Steam 状态", steamOk ? "✅ 运行中" : "❌ 未运行"); + + if (!steamOk) { - RunCsvRoundtripTest(); + EditorGUILayout.HelpBox("Steam 未运行,无法查询创意工坊。", MessageType.Warning); + return; } + EditorGUILayout.Space(4); + + // 翻页控件 + var browser = WorkshopModBrowser.Instance; + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField( + browser.TotalResults > 0 + ? $"共 {browser.TotalResults} 个结果 | 第 {browser.CurrentPage}/{browser.TotalPages} 页" + : "尚未查询", + GUILayout.ExpandWidth(true)); + + using (new EditorGUI.DisabledScope(browser.IsQuerying)) + { + if (GUILayout.Button("查询第1页", GUILayout.Width(80))) + { + _browseStatus = "查询中..."; + browser.QueryPage(1); + } + } + using (new EditorGUI.DisabledScope(browser.IsQuerying || browser.CurrentPage <= 1)) + { + if (GUILayout.Button("◀ 上一页", GUILayout.Width(70))) + { + _browseStatus = "查询中..."; + browser.QueryPage(browser.CurrentPage - 1); + } + } + using (new EditorGUI.DisabledScope(browser.IsQuerying || browser.CurrentPage >= browser.TotalPages)) + { + if (GUILayout.Button("下一页 ▶", GUILayout.Width(70))) + { + _browseStatus = "查询中..."; + browser.QueryPage(browser.CurrentPage + 1); + } + } + EditorGUILayout.EndHorizontal(); + + if (!string.IsNullOrEmpty(_browseStatus)) + { + EditorGUILayout.HelpBox(_browseStatus, MessageType.None); + } + + EditorGUILayout.Space(4); + + // Mod 列表 + if (browser.Results.Count == 0 && !browser.IsQuerying) + { + EditorGUILayout.LabelField("暂无结果,请点击「查询第1页」", EditorStyles.centeredGreyMiniLabel); + return; + } + + _browseScroll = EditorGUILayout.BeginScrollView(_browseScroll); + foreach (var item in browser.Results) + { + EditorGUILayout.BeginVertical("box"); + + // 标题行 + EditorGUILayout.BeginHorizontal(); + string subscribedLabel = item.IsSubscribed + ? (item.IsInstalled ? "✅ 已订阅+安装" : "⏳ 已订阅(下载中)") + : " 未订阅"; + EditorGUILayout.LabelField( + $"{subscribedLabel} [{item.FileId}] {item.Title}", + EditorStyles.boldLabel, GUILayout.ExpandWidth(true)); + + bool opBusy = browser.IsSubscribeOperating; + if (item.IsSubscribed) + { + using (new EditorGUI.DisabledScope(opBusy)) + { + if (GUILayout.Button("取消订阅", GUILayout.Width(72))) + { + _browseStatus = $"正在取消订阅 {item.Title}..."; + browser.Unsubscribe(item.FileId); + } + } + } + else + { + using (new EditorGUI.DisabledScope(opBusy)) + { + if (GUILayout.Button("订阅", GUILayout.Width(48))) + { + _browseStatus = $"正在订阅 {item.Title}..."; + browser.Subscribe(item.FileId); + } + } + } + EditorGUILayout.EndHorizontal(); + + // 详情 + if (!string.IsNullOrEmpty(item.Tags)) + EditorGUILayout.LabelField($"标签: {item.Tags}", EditorStyles.miniLabel); + if (!string.IsNullOrEmpty(item.Description)) + EditorGUILayout.LabelField(Truncate(item.Description, 120), EditorStyles.wordWrappedMiniLabel); + EditorGUILayout.LabelField($"👍 {item.VotesUp} 👎 {item.VotesDown} 大小: {item.FileSize / 1024:N0} KB", + EditorStyles.miniLabel); + if (item.IsInstalled && !string.IsNullOrEmpty(item.InstallFolder)) + EditorGUILayout.LabelField($"安装路径: {item.InstallFolder}", EditorStyles.miniLabel); + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(2); + } + EditorGUILayout.EndScrollView(); + } + + private void OnBrowseQueryCompleted() + { + var browser = WorkshopModBrowser.Instance; + _browseStatus = string.IsNullOrEmpty(browser.LastError) + ? $"✅ 查询完成,共 {browser.Results.Count} 条结果(总计 {browser.TotalResults} 个)" + : $"❌ {browser.LastError}"; + Repaint(); + } + + private void OnBrowseSubscribeCompleted(PublishedFileId_t fileId, bool success) + { + _browseStatus = success + ? $"✅ 订阅成功 [{fileId}]" + : $"❌ 订阅失败 [{fileId}],请查看 Console"; + Repaint(); + } + + private void OnBrowseUnsubscribeCompleted(PublishedFileId_t fileId, bool success) + { + _browseStatus = success + ? $"✅ 已取消订阅 [{fileId}]" + : $"❌ 取消订阅失败 [{fileId}],请查看 Console"; + Repaint(); + } + + // ═══════════════════════════════════════════════════════════ + // ⑥ CSV 读写测试 + // ═══════════════════════════════════════════════════════════ + private void DrawCsvTestTab() + { + EditorGUILayout.LabelField("CSV 读写回环测试(4列格式)", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "测试 WorkshopModCsv 的 WriteCsv / ReadCsv 方法(4列:ID | EN | 参考语言 | Translation)。\n" + + "只有 Translation 列非空的行才会被读取为有效条目。", + MessageType.Info); + + EditorGUILayout.Space(4); + _csvTestRefLang = (MultilingualType)EditorGUILayout.EnumPopup("参考语言(第3列标题)", _csvTestRefLang); + + EditorGUILayout.Space(4); + if (GUILayout.Button("🧪 运行自动回环测试(含特殊字符)", GUILayout.Height(28))) + RunCsvRoundtripTest(); + EditorGUILayout.Space(8); EditorGUILayout.LabelField("手动 CSV 解析测试", EditorStyles.boldLabel); - EditorGUILayout.LabelField("输入 CSV 内容:"); _csvInputScroll = EditorGUILayout.BeginScrollView(_csvInputScroll, GUILayout.Height(120)); _csvTestInput = EditorGUILayout.TextArea(_csvTestInput, GUILayout.ExpandHeight(true)); EditorGUILayout.EndScrollView(); EditorGUILayout.BeginHorizontal(); - if (GUILayout.Button("解析 CSV")) - { - DoParseCsv(); - } - if (GUILayout.Button("生成示例 CSV")) - { - _csvTestInput = GenerateSampleCsv(); - } + if (GUILayout.Button("解析 CSV")) DoParseCsv(); + if (GUILayout.Button("生成示例 CSV")) _csvTestInput = GenerateSampleCsv(); EditorGUILayout.EndHorizontal(); if (!string.IsNullOrEmpty(_csvTestOutput)) { EditorGUILayout.Space(4); EditorGUILayout.LabelField("解析结果:"); - _csvOutputScroll = EditorGUILayout.BeginScrollView(_csvOutputScroll, GUILayout.Height(160)); - EditorGUILayout.TextArea(_csvTestOutput, EditorStyles.wordWrappedLabel, - GUILayout.ExpandHeight(true)); + _csvOutputScroll = EditorGUILayout.BeginScrollView(_csvOutputScroll, GUILayout.Height(180)); + EditorGUILayout.TextArea(_csvTestOutput, EditorStyles.wordWrappedLabel, GUILayout.ExpandHeight(true)); EditorGUILayout.EndScrollView(); } } @@ -443,47 +754,44 @@ public class WorkshopModEditorWindow : EditorWindow private void RunCsvRoundtripTest() { var sb = new StringBuilder(); - sb.AppendLine("══════ CSV 回环测试 ══════"); + sb.AppendLine("══════ CSV 4列回环测试 ══════"); - // 构造含特殊字符的测试数据 + // 构造含特殊字符的测试数据(Translation 非空才会被读出) var entries = new List { - new TranslationEntry { Id = 1, Reference = "普通文本", Translation = "Normal text" }, - new TranslationEntry { Id = 2, Reference = "含逗号,的文本", Translation = "Text, with comma" }, - new TranslationEntry { Id = 3, Reference = "含\"引号\"的文本", Translation = "Text with \"quotes\"" }, - new TranslationEntry { Id = 4, Reference = "含\n换行\n的文本", Translation = "Text\nwith\nnewlines" }, - new TranslationEntry { Id = 5, Reference = "混合,特殊\"字符\"\n都有", Translation = "Mixed,special\"chars\"\nall" }, - new TranslationEntry { Id = 6, Reference = "**<123>** 嵌套引用", Translation = "**<123>** nested ref" }, - new TranslationEntry { Id = 7, Reference = "", Translation = "" }, + new TranslationEntry { Id=1, EnglishText="Normal text", ReferenceText="普通文本", Translation="Normal text translated" }, + new TranslationEntry { Id=2, EnglishText="Text, with comma", ReferenceText="含逗号,的文本", Translation="翻译,含逗号" }, + new TranslationEntry { Id=3, EnglishText="Text with \"quotes\"", ReferenceText="含\"引号\"的文本", Translation="翻译\"引号\"" }, + new TranslationEntry { Id=4, EnglishText="Text\nwith\nnewlines", ReferenceText="含\n换行\n文本", Translation="换行\n翻译" }, + new TranslationEntry { Id=5, EnglishText="**<123>** nested ref", ReferenceText="**<123>** 嵌套引用", Translation="**<123>** 嵌套翻译" }, + new TranslationEntry { Id=6, EnglishText="Empty translation", ReferenceText="空翻译条目", Translation="" }, // 应被跳过 }; - // Write - var csv = WorkshopModCsv.WriteCsv(MultilingualType.EN, entries); + var csv = WorkshopModCsv.WriteCsv(_csvTestRefLang, entries); sb.AppendLine("[生成的 CSV]:"); sb.AppendLine(csv); - // Read - var parsed = WorkshopModCsv.ReadCsv(csv, out string headerLang); - sb.AppendLine($"[解析结果] 表头语言: {headerLang}, 有效条目数: {parsed.Count}"); + var parsed = WorkshopModCsv.ReadCsv(csv, out string refLang); + sb.AppendLine($"[解析结果] 参考语言: {refLang}, 有效条目数: {parsed.Count}"); - // 逐条验证(空翻译条目会被跳过,所以排除 ID=7) - int passed = 0; - int failed = 0; + int passed = 0, failed = 0; var validEntries = entries.FindAll(e => !string.IsNullOrEmpty(e.Translation)); + for (int i = 0; i < validEntries.Count; i++) { var expected = validEntries[i]; if (i >= parsed.Count) { - sb.AppendLine($" ❌ ID={expected.Id}: 缺失(parsed 数量不足)"); + sb.AppendLine($" ❌ ID={expected.Id}: 缺失"); failed++; continue; } var actual = parsed[i]; - bool idOk = actual.Id == expected.Id; - bool refOk = actual.Reference == expected.Reference; - bool transOk = actual.Translation == expected.Translation; - if (idOk && refOk && transOk) + bool idOk = actual.Id == expected.Id; + bool enOk = actual.EnglishText == expected.EnglishText; + bool refOk = actual.ReferenceText == expected.ReferenceText; + bool transOk = actual.Translation == expected.Translation; + if (idOk && enOk && refOk && transOk) { sb.AppendLine($" ✅ ID={expected.Id}: 通过"); passed++; @@ -491,74 +799,66 @@ public class WorkshopModEditorWindow : EditorWindow else { sb.AppendLine($" ❌ ID={expected.Id}: 失败"); - if (!idOk) sb.AppendLine($" ID: 期望={expected.Id} 实际={actual.Id}"); - if (!refOk) sb.AppendLine($" Reference: 期望=\"{Escape(expected.Reference)}\" 实际=\"{Escape(actual.Reference)}\""); - if (!transOk) sb.AppendLine($" Translation: 期望=\"{Escape(expected.Translation)}\" 实际=\"{Escape(actual.Translation)}\""); + if (!idOk) sb.AppendLine($" ID: 期望={expected.Id} 实际={actual.Id}"); + if (!enOk) sb.AppendLine($" EN: 期望=\"{Escape(expected.EnglishText)}\" 实际=\"{Escape(actual.EnglishText)}\""); + if (!refOk) sb.AppendLine($" Reference: 期望=\"{Escape(expected.ReferenceText)}\" 实际=\"{Escape(actual.ReferenceText)}\""); + if (!transOk) sb.AppendLine($" Translation: 期望=\"{Escape(expected.Translation)}\" 实际=\"{Escape(actual.Translation)}\""); failed++; } } - // 检查空条目被正确跳过 + // 验证空 Translation 被跳过 bool emptySkipped = parsed.Count == validEntries.Count; - if (emptySkipped) { sb.AppendLine($" ✅ 空翻译条目: 正确跳过"); passed++; } - else { sb.AppendLine($" ❌ 空翻译条目: 未正确跳过 (期望 {validEntries.Count} 条,实际 {parsed.Count} 条)"); failed++; } + if (emptySkipped) { sb.AppendLine(" ✅ 空 Translation 条目: 正确跳过"); passed++; } + else { sb.AppendLine($" ❌ 空 Translation 条目: 未正确跳过 (期望 {validEntries.Count} 条,实际 {parsed.Count} 条)"); failed++; } sb.AppendLine($"\n══════ 测试结果: {passed} 通过, {failed} 失败 ══════"); _csvTestOutput = sb.ToString(); - if (failed == 0) Debug.Log($"✅ CSV 回环测试全部通过 ({passed}/{passed})"); - else Debug.LogError($"❌ CSV 回环测试 {failed} 项失败"); + if (failed == 0) Debug.Log($"✅ CSV 4列回环测试全部通过 ({passed}/{passed})"); + else Debug.LogError($"❌ CSV 回环测试 {failed} 项失败"); } private static string Escape(string s) => s?.Replace("\n", "\\n").Replace("\r", "\\r") ?? "(null)"; private void DoParseCsv() { - if (string.IsNullOrEmpty(_csvTestInput)) - { - _csvTestOutput = "输入为空"; - return; - } + if (string.IsNullOrEmpty(_csvTestInput)) { _csvTestOutput = "输入为空"; return; } - var entries = WorkshopModCsv.ReadCsv(_csvTestInput, out string headerLang); + var entries = WorkshopModCsv.ReadCsv(_csvTestInput, out string refLang); var sb = new StringBuilder(); - sb.AppendLine($"表头语言: {headerLang}"); - sb.AppendLine($"有效条目数: {entries.Count}"); + sb.AppendLine($"参考语言(第3列): {refLang}"); + sb.AppendLine($"有效条目数(Translation非空): {entries.Count}"); sb.AppendLine("---"); foreach (var e in entries) { - sb.AppendLine($"ID={e.Id} 参考=\"{Escape(e.Reference)}\" 翻译=\"{Escape(e.Translation)}\""); + sb.AppendLine($"ID={e.Id} EN=\"{Escape(e.EnglishText)}\" Ref=\"{Escape(e.ReferenceText)}\" Translation=\"{Escape(e.Translation)}\""); } _csvTestOutput = sb.ToString(); } private string GenerateSampleCsv() { - return "ID,ZH,EN\n" + - "1,你好世界,Hello World\n" + - "2,\"含逗号,的文本\",\"Text, with comma\"\n" + - "3,\"含\"\"引号\"\"的文本\",\"With \"\"quotes\"\"\"\n" + - "4,\"含\n换行的\n文本\",\"Multi\nline\"\n" + - "5,空翻译条目,\n"; + // 4列示例,Translation列:前两行填写,第三行留空(不替换) + return $"ID,EN,{_csvTestRefLang},Translation\n" + + "1,Hello World,你好世界,Hola Mundo\n" + + "2,\"Text, with comma\",\"含逗号,的文本\",\"Texto, con coma\"\n" + + "3,Empty translation,空翻译不替换,\n"; } // ═══════════════════════════════════════════════════════════ - // ⑤ 数据检视 + // ⑦ 数据检视 // ═══════════════════════════════════════════════════════════ private void DrawInspectTab() { EditorGUILayout.LabelField("数据检视", EditorStyles.boldLabel); - EditorGUILayout.HelpBox( - "查看多语言数据资源和 Mod 文件内容,用于验证导出/加载结果。", - MessageType.Info); + EditorGUILayout.HelpBox("查看多语言数据资源和 Mod 文件内容,用于验证导出/加载结果。", MessageType.Info); // 多语言数据概览 EditorGUILayout.Space(4); EditorGUILayout.LabelField("多语言数据概览", EditorStyles.boldLabel); if (GUILayout.Button("加载 Export/Multilingual 资源信息")) - { InspectMultilingualData(); - } // ID 查询 EditorGUILayout.Space(4); @@ -566,15 +866,11 @@ public class WorkshopModEditorWindow : EditorWindow EditorGUILayout.BeginHorizontal(); _inspectSearchId = (uint)EditorGUILayout.IntField("多语言 ID", (int)_inspectSearchId); if (GUILayout.Button("查询", GUILayout.Width(60))) - { InspectSearchById(); - } EditorGUILayout.EndHorizontal(); if (!string.IsNullOrEmpty(_inspectSearchResult)) - { EditorGUILayout.TextArea(_inspectSearchResult, EditorStyles.wordWrappedLabel); - } // Mod 文件夹内容 EditorGUILayout.Space(8); @@ -585,25 +881,18 @@ public class WorkshopModEditorWindow : EditorWindow { var selected = EditorUtility.OpenFolderPanel("选择 Mod 文件夹", WorkshopModExporter.GetModRootPath(), ""); - if (!string.IsNullOrEmpty(selected)) - { - _inspectFolder = selected; - InspectModFolder(); - } + if (!string.IsNullOrEmpty(selected)) { _inspectFolder = selected; InspectModFolder(); } } EditorGUILayout.EndHorizontal(); if (GUILayout.Button("检视文件夹") && !string.IsNullOrEmpty(_inspectFolder)) - { InspectModFolder(); - } if (!string.IsNullOrEmpty(_inspectResult)) { EditorGUILayout.Space(4); _inspectScroll = EditorGUILayout.BeginScrollView(_inspectScroll, GUILayout.MaxHeight(320)); - EditorGUILayout.TextArea(_inspectResult, EditorStyles.wordWrappedLabel, - GUILayout.ExpandHeight(true)); + EditorGUILayout.TextArea(_inspectResult, EditorStyles.wordWrappedLabel, GUILayout.ExpandHeight(true)); EditorGUILayout.EndScrollView(); } } @@ -611,11 +900,7 @@ public class WorkshopModEditorWindow : EditorWindow private void InspectMultilingualData() { var data = Resources.Load("Export/Multilingual"); - if (data == null) - { - _inspectResult = "❌ 无法加载 Export/Multilingual"; - return; - } + if (data == null) { _inspectResult = "❌ 无法加载 Export/Multilingual"; return; } data.RefreshDict(); var sb = new StringBuilder(); @@ -623,12 +908,10 @@ public class WorkshopModEditorWindow : EditorWindow sb.AppendLine($"总条目数: {data.Items.Count}"); sb.AppendLine($"字典条目数: {data.ItemDict?.Count ?? 0}"); sb.AppendLine($"字体组数: {data.FontGroups.Count}"); - sb.AppendLine($"语种映射数: {data.TargetTypes.Count}"); - // 统计各语言翻译覆盖率 - int total = data.Items.Count; - int deprecated = 0; + int total = data.Items.Count, deprecated = 0; var langCounts = new Dictionary(); + // 包含 Custom 语种 for (var t = MultilingualType.ZH; t < MultilingualType.Max; t++) langCounts[t] = 0; @@ -637,8 +920,7 @@ public class WorkshopModEditorWindow : EditorWindow if (item.IsDeprecated) { deprecated++; continue; } for (var t = MultilingualType.ZH; t < MultilingualType.Max; t++) { - if (!string.IsNullOrEmpty(item.GetStrByType(t))) - langCounts[t]++; + if (!string.IsNullOrEmpty(item.GetStrByType(t))) langCounts[t]++; } } @@ -651,13 +933,12 @@ public class WorkshopModEditorWindow : EditorWindow sb.AppendLine($" {kv.Key}: {kv.Value}/{active} ({pct:F1}%)"); } - // 前 5 条示例 sb.AppendLine("--- 前 5 条数据示例 ---"); int shown = 0; foreach (var item in data.Items) { if (item.IsDeprecated) continue; - sb.AppendLine($" [{item.ID}] ZH=\"{Truncate(item.ZH, 30)}\" EN=\"{Truncate(item.EN, 30)}\""); + sb.AppendLine($" [{item.ID}] EN=\"{Truncate(item.EN, 30)}\" ZH=\"{Truncate(item.ZH, 30)}\""); if (++shown >= 5) break; } @@ -667,11 +948,7 @@ public class WorkshopModEditorWindow : EditorWindow private void InspectSearchById() { var data = Resources.Load("Export/Multilingual"); - if (data == null) - { - _inspectSearchResult = "❌ 无法加载资源"; - return; - } + if (data == null) { _inspectSearchResult = "❌ 无法加载资源"; return; } data.RefreshDict(); if (data.ItemDict == null || !data.ItemDict.TryGetValue(_inspectSearchId, out var item)) @@ -684,13 +961,12 @@ public class WorkshopModEditorWindow : EditorWindow sb.AppendLine($"═══ ID={item.ID} ═══"); sb.AppendLine($"IsDeprecated={item.IsDeprecated} IsProperNoun={item.IsProperNoun} IsDialogue={item.IsDialogue}"); if (!string.IsNullOrEmpty(item.Color)) sb.AppendLine($"Color={item.Color}"); - if (!string.IsNullOrEmpty(item.Icon)) sb.AppendLine($"Icon={item.Icon}"); + if (!string.IsNullOrEmpty(item.Icon)) sb.AppendLine($"Icon={item.Icon}"); sb.AppendLine("--- 各语言文本 ---"); + // 包含 Custom for (var t = MultilingualType.ZH; t < MultilingualType.Max; t++) - { - var text = item.GetStrByType(t); - sb.AppendLine($" {t}: \"{Truncate(text, 80)}\""); - } + sb.AppendLine($" {t}: \"{Truncate(item.GetStrByType(t), 80)}\""); + _inspectSearchResult = sb.ToString(); } @@ -705,75 +981,49 @@ public class WorkshopModEditorWindow : EditorWindow var sb = new StringBuilder(); sb.AppendLine($"═══ Mod 文件夹: {Path.GetFileName(_inspectFolder)} ═══"); - // 列出所有文件 var files = Directory.GetFiles(_inspectFolder); sb.AppendLine($"文件数: {files.Length}"); foreach (var f in files) sb.AppendLine($" {Path.GetFileName(f)} ({new FileInfo(f).Length:N0} bytes)"); - // 解析 mod_info.json var modInfoPath = Path.Combine(_inspectFolder, WorkshopModExporter.ModInfoFileName); if (File.Exists(modInfoPath)) { sb.AppendLine("\n--- mod_info.json ---"); try { - var json = File.ReadAllText(modInfoPath); - var info = JsonUtility.FromJson(json); + var info = JsonUtility.FromJson(File.ReadAllText(modInfoPath)); sb.AppendLine($" title: {info.title}"); sb.AppendLine($" author: {info.author}"); sb.AppendLine($" targetLanguage: {info.targetLanguage}"); sb.AppendLine($" description: {info.description}"); sb.AppendLine($" version: {info.version}"); } - catch (Exception e) - { - sb.AppendLine($" ❌ 解析失败: {e.Message}"); - } - } - else - { - sb.AppendLine("\n❌ mod_info.json 不存在"); + catch (Exception e) { sb.AppendLine($" ❌ 解析失败: {e.Message}"); } } + else sb.AppendLine("\n❌ mod_info.json 不存在"); - // 解析 translation.csv var csvPath = Path.Combine(_inspectFolder, WorkshopModExporter.TranslationFileName); if (File.Exists(csvPath)) { - sb.AppendLine("\n--- translation.csv ---"); + sb.AppendLine("\n--- translation.csv (4列格式) ---"); try { var csvContent = File.ReadAllText(csvPath); - var entries = WorkshopModCsv.ReadCsv(csvContent, out string headerLang); - sb.AppendLine($" 表头语言: {headerLang}"); - sb.AppendLine($" 有效翻译条目: {entries.Count}"); - int nonEmpty = 0; - foreach (var e in entries) - { - if (!string.IsNullOrEmpty(e.Translation)) nonEmpty++; - } - sb.AppendLine($" 非空翻译条目: {nonEmpty}"); - - // 显示前 10 条 + var entries = WorkshopModCsv.ReadCsv(csvContent, out string refLang); + sb.AppendLine($" 参考语言(第3列): {refLang}"); + sb.AppendLine($" 有效翻译条目(Translation非空): {entries.Count}"); sb.AppendLine(" --- 前 10 条 ---"); for (int i = 0; i < Math.Min(10, entries.Count); i++) { var e = entries[i]; - sb.AppendLine( - $" [{e.Id}] ZH=\"{Truncate(e.Reference, 20)}\" → \"{Truncate(e.Translation, 20)}\""); + sb.AppendLine($" [{e.Id}] EN=\"{Truncate(e.EnglishText,20)}\" Ref=\"{Truncate(e.ReferenceText,20)}\" → \"{Truncate(e.Translation,20)}\""); } - if (entries.Count > 10) - sb.AppendLine($" ... 还有 {entries.Count - 10} 条"); - } - catch (Exception e) - { - sb.AppendLine($" ❌ 解析失败: {e.Message}"); + if (entries.Count > 10) sb.AppendLine($" ... 还有 {entries.Count - 10} 条"); } + catch (Exception e) { sb.AppendLine($" ❌ 解析失败: {e.Message}"); } } - else - { - sb.AppendLine("\n❌ translation.csv 不存在"); - } + else sb.AppendLine("\n❌ translation.csv 不存在"); _inspectResult = sb.ToString(); } diff --git a/Unity/Assets/Scripts/TH1_Logic/Config/ConfigManager.cs b/Unity/Assets/Scripts/TH1_Logic/Config/ConfigManager.cs index f6ceec5ab..f3778159d 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Config/ConfigManager.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Config/ConfigManager.cs @@ -7,6 +7,7 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using ExcelConfig; @@ -16,6 +17,7 @@ using Logic.Multilingual; using TH1_Logic.Tools; using UnityEngine; + namespace TH1_Logic.Config { public class ConfigManager @@ -181,6 +183,20 @@ namespace TH1_Logic.Config } + [Serializable] + public class ModLanguageConfig + { + public MultilingualType Language; + /// + /// 按优先级排序的 Mod 文件夹路径列表 + /// 索引 0 = 最低优先级(最先应用),最后一项 = 最高优先级(最后应用,可覆盖前面的) + /// + public List ModPaths = new List(); + + public ModLanguageConfig() { } + public ModLanguageConfig(MultilingualType language) { Language = language; } + } + [Serializable] public class GameConfig { @@ -196,6 +212,8 @@ namespace TH1_Logic.Config private bool _keyMomentEnabled; [SerializeField] private bool _bgmContinuousPlay; + [SerializeField] + private List _modLanguageConfigs = new List(); private bool _isChanged; public bool IsChanged => _isChanged; @@ -274,6 +292,76 @@ namespace TH1_Logic.Config _showReminder = true; _keyMomentEnabled = true; _bgmContinuousPlay = false; + _modLanguageConfigs = new List(); + } + + // ── Mod 优先级配置接口 ── + + /// 获取所有语种的 Mod 配置列表(只读) + public List ModLanguageConfigs => _modLanguageConfigs; + + /// 获取指定语种的 Mod 配置,不存在则创建 + public ModLanguageConfig GetOrCreateModConfig(MultilingualType language) + { + var config = _modLanguageConfigs.Find(c => c.Language == language); + if (config != null) return config; + config = new ModLanguageConfig(language); + _modLanguageConfigs.Add(config); + _isChanged = true; + return config; + } + + /// 设置指定语种的 Mod 优先级列表(覆盖现有) + public void SetModsForLanguage(MultilingualType language, List orderedPaths) + { + var config = GetOrCreateModConfig(language); + config.ModPaths = new List(orderedPaths); + _isChanged = true; + } + + /// 向指定语种添加一个 Mod(加到列表末尾,最高优先级) + public void AddModToLanguage(MultilingualType language, string modPath) + { + var config = GetOrCreateModConfig(language); + if (!config.ModPaths.Contains(modPath)) + { + config.ModPaths.Add(modPath); + _isChanged = true; + } + } + + /// 从指定语种移除一个 Mod + public void RemoveModFromLanguage(MultilingualType language, string modPath) + { + var config = _modLanguageConfigs.Find(c => c.Language == language); + if (config == null) return; + if (config.ModPaths.Remove(modPath)) _isChanged = true; + } + + /// 移动指定语种 Mod 的优先级(向上提升=降低索引,向下降低=增加索引) + public void MoveModPriority(MultilingualType language, int fromIndex, int toIndex) + { + var config = _modLanguageConfigs.Find(c => c.Language == language); + if (config == null) return; + var paths = config.ModPaths; + if (fromIndex < 0 || fromIndex >= paths.Count) return; + if (toIndex < 0 || toIndex >= paths.Count) return; + var item = paths[fromIndex]; + paths.RemoveAt(fromIndex); + paths.Insert(toIndex, item); + _isChanged = true; + } + + /// 清除指定语种的所有 Mod 配置 + public void ClearModsForLanguage(MultilingualType language) + { + var config = _modLanguageConfigs.Find(c => c.Language == language); + if (config == null) return; + if (config.ModPaths.Count > 0) + { + config.ModPaths.Clear(); + _isChanged = true; + } } } } \ No newline at end of file diff --git a/Unity/Assets/Scripts/TH1_Logic/Multilingual/MultilingualData.cs b/Unity/Assets/Scripts/TH1_Logic/Multilingual/MultilingualData.cs index 360217119..3eb8f2646 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Multilingual/MultilingualData.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Multilingual/MultilingualData.cs @@ -30,6 +30,7 @@ namespace Logic.Multilingual ES, // 西班牙语 PT, // 葡萄牙语 FR, // 法语 + Custom, // 自定义语种(仅供 Mod 应用使用,不参与常规多语言流程) Max, } @@ -73,6 +74,7 @@ namespace Logic.Multilingual MultilingualType.ES => group.ESFont, MultilingualType.PT => group.PTFont, MultilingualType.FR => group.FRFont, + MultilingualType.Custom => group.ENFont, // Custom 语种回退到 EN 字体 _ => null, }; } @@ -405,6 +407,7 @@ namespace Logic.Multilingual public string ES; public string PT; public string FR; + public string Custom; // 自定义语种(仅供 Mod 应用使用) public bool IsProperNoun; public bool IsDialogue; public string DialogueSpeaker; @@ -435,6 +438,7 @@ namespace Logic.Multilingual ES = ES?.Replace("\r\n", "\n") ?? string.Empty; PT = PT?.Replace("\r\n", "\n") ?? string.Empty; FR = FR?.Replace("\r\n", "\n") ?? string.Empty; + Custom = Custom?.Replace("\r\n", "\n") ?? string.Empty; DialogueSpeaker = DialogueSpeaker?.Replace("\r\n", "\n") ?? string.Empty; Desc = Desc?.Replace("\r\n", "\n") ?? string.Empty; } @@ -452,6 +456,7 @@ namespace Logic.Multilingual MultilingualType.ES => ES, MultilingualType.PT => PT, MultilingualType.FR => FR, + MultilingualType.Custom => Custom, _ => string.Empty, }; } @@ -477,6 +482,7 @@ namespace Logic.Multilingual MultilingualType.ES => !string.IsNullOrEmpty(ES), MultilingualType.PT => !string.IsNullOrEmpty(PT), MultilingualType.FR => !string.IsNullOrEmpty(FR), + MultilingualType.Custom => !string.IsNullOrEmpty(Custom), _ => false, }; } diff --git a/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModBrowser.cs b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModBrowser.cs new file mode 100644 index 000000000..8129d4380 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModBrowser.cs @@ -0,0 +1,251 @@ +/* + * @Author: 白哉 + * @Description: 创意工坊 Mod 浏览器(查询当前 AppId 的所有 Workshop 物品,支持订阅/取消订阅) + * @Date: 2026年04月16日 + * @Modify: + */ + +using System; +using System.Collections.Generic; +using Logic.CrashSight; +using Steamworks; + +namespace Logic.Multilingual +{ + /// + /// Workshop 物品的简要信息(用于列表展示) + /// + public class WorkshopModItem + { + public PublishedFileId_t FileId; + public string Title; + public string Description; + public string Tags; + public bool IsSubscribed; + public bool IsInstalled; + public string InstallFolder; + public ulong FileSize; + public uint VotesUp; + public uint VotesDown; + } + + /// + /// 创意工坊 Mod 浏览器,基于 Steamworks ISteamUGC 接口 + /// 支持分页查询、订阅、取消订阅操作 + /// + public class WorkshopModBrowser + { + public static readonly WorkshopModBrowser Instance = new WorkshopModBrowser(); + + public const uint PageSize = 50; + + // ── 查询状态 ── + public bool IsQuerying { get; private set; } + public uint CurrentPage { get; private set; } = 1; + public uint TotalResults { get; private set; } + public uint TotalPages => TotalResults == 0 ? 0 : (TotalResults + PageSize - 1) / PageSize; + public List Results { get; } = new List(); + public string LastError { get; private set; } = ""; + + // ── 订阅/取消订阅状态 ── + public bool IsSubscribeOperating { get; private set; } + + private CallResult _queryCallResult; + private CallResult _subscribeCallResult; + private CallResult _unsubscribeCallResult; + + private UGCQueryHandle_t _pendingQueryHandle; + + public event System.Action OnQueryCompleted; + public event Action OnSubscribeCompleted; + public event Action OnUnsubscribeCompleted; + + /// + /// 查询当前 AppId 的所有 Workshop Mod(分页) + /// + public void QueryPage(uint page = 1) + { + if (IsQuerying) return; + + try + { + if (!SteamAPI.IsSteamRunning()) + { + LastError = "Steam 未运行"; + return; + } + + IsQuerying = true; + CurrentPage = page; + LastError = ""; + + var appId = SteamUtils.GetAppID(); + var handle = SteamUGC.CreateQueryAllUGCRequest( + EUGCQuery.k_EUGCQuery_RankedByPublicationDate, + EUGCMatchingUGCType.k_EUGCMatchingUGCType_Items_ReadyToUse, + new AppId_t(0), // creatorAppId: any + appId, + page); + + SteamUGC.SetReturnLongDescription(handle, true); + SteamUGC.SetReturnTotalOnly(handle, false); + + _pendingQueryHandle = handle; + var apiCall = SteamUGC.SendQueryUGCRequest(handle); + + _queryCallResult = CallResult.Create(OnSteamQueryCompleted); + _queryCallResult.Set(apiCall); + } + catch (Exception e) + { + IsQuerying = false; + LastError = $"查询异常: {e.Message}"; + LogSystem.LogError($"WorkshopModBrowser: 查询失败 - {e.Message}"); + } + } + + /// + /// 订阅指定 Workshop 物品 + /// + public void Subscribe(PublishedFileId_t fileId) + { + if (IsSubscribeOperating) return; + try + { + IsSubscribeOperating = true; + var apiCall = SteamUGC.SubscribeItem(fileId); + _subscribeCallResult = CallResult.Create( + (result, ioFail) => + { + IsSubscribeOperating = false; + bool success = !ioFail && result.m_eResult == EResult.k_EResultOK; + if (success) + { + // 更新本地结果状态 + RefreshItemState(fileId); + LogSystem.LogInfo($"WorkshopModBrowser: 已订阅 {fileId}"); + } + else + { + LogSystem.LogError($"WorkshopModBrowser: 订阅失败 {fileId}, Result={result.m_eResult}"); + } + OnSubscribeCompleted?.Invoke(fileId, success); + }); + _subscribeCallResult.Set(apiCall); + } + catch (Exception e) + { + IsSubscribeOperating = false; + LogSystem.LogError($"WorkshopModBrowser: 订阅异常 - {e.Message}"); + OnSubscribeCompleted?.Invoke(fileId, false); + } + } + + /// + /// 取消订阅指定 Workshop 物品 + /// + public void Unsubscribe(PublishedFileId_t fileId) + { + if (IsSubscribeOperating) return; + try + { + IsSubscribeOperating = true; + var apiCall = SteamUGC.UnsubscribeItem(fileId); + _unsubscribeCallResult = CallResult.Create( + (result, ioFail) => + { + IsSubscribeOperating = false; + bool success = !ioFail && result.m_eResult == EResult.k_EResultOK; + if (success) + { + RefreshItemState(fileId); + LogSystem.LogInfo($"WorkshopModBrowser: 已取消订阅 {fileId}"); + } + else + { + LogSystem.LogError($"WorkshopModBrowser: 取消订阅失败 {fileId}, Result={result.m_eResult}"); + } + OnUnsubscribeCompleted?.Invoke(fileId, success); + }); + _unsubscribeCallResult.Set(apiCall); + } + catch (Exception e) + { + IsSubscribeOperating = false; + LogSystem.LogError($"WorkshopModBrowser: 取消订阅异常 - {e.Message}"); + OnUnsubscribeCompleted?.Invoke(fileId, false); + } + } + + /// + /// 刷新 Steam 回调(需外部在合适的时机定期调用,如 EditorApplication.update 或游戏的 Update) + /// + public void RunCallbacks() + { + try { SteamAPI.RunCallbacks(); } catch { } + } + + private void OnSteamQueryCompleted(SteamUGCQueryCompleted_t result, bool ioFailure) + { + IsQuerying = false; + + if (ioFailure || result.m_eResult != EResult.k_EResultOK) + { + LastError = $"查询失败: Result={result.m_eResult}"; + LogSystem.LogError($"WorkshopModBrowser: {LastError}"); + SteamUGC.ReleaseQueryUGCRequest(result.m_handle); + OnQueryCompleted?.Invoke(); + return; + } + + Results.Clear(); + TotalResults = result.m_unTotalMatchingResults; + + for (uint i = 0; i < result.m_unNumResultsReturned; i++) + { + if (!SteamUGC.GetQueryUGCResult(result.m_handle, i, out var details)) continue; + + var item = new WorkshopModItem + { + FileId = details.m_nPublishedFileId, + Title = details.m_rgchTitle, + Description = details.m_rgchDescription, + Tags = details.m_rgchTags, + VotesUp = details.m_unVotesUp, + VotesDown = details.m_unVotesDown, + FileSize = (ulong)details.m_nFileSize, + }; + + RefreshItemStateForItem(item); + Results.Add(item); + } + + SteamUGC.ReleaseQueryUGCRequest(result.m_handle); + OnQueryCompleted?.Invoke(); + } + + private void RefreshItemState(PublishedFileId_t fileId) + { + var item = Results.Find(r => r.FileId == fileId); + if (item != null) RefreshItemStateForItem(item); + } + + private void RefreshItemStateForItem(WorkshopModItem item) + { + var state = (EItemState)SteamUGC.GetItemState(item.FileId); + item.IsSubscribed = (state & EItemState.k_EItemStateSubscribed) != 0; + item.IsInstalled = (state & EItemState.k_EItemStateInstalled) != 0; + + if (item.IsInstalled && + SteamUGC.GetItemInstallInfo(item.FileId, out ulong size, out string folder, 1024, out _)) + { + item.InstallFolder = folder; + item.FileSize = size; + } + else + { + item.InstallFolder = ""; + } + } + } +} diff --git a/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModBrowser.cs.meta b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModBrowser.cs.meta new file mode 100644 index 000000000..af47b38a6 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModBrowser.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 46d2fb86290a5344c9569512a293e1f1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModData.cs b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModData.cs index 6406b9fdf..90fa25667 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModData.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModData.cs @@ -30,42 +30,49 @@ namespace Logic.Multilingual public struct TranslationEntry { public uint Id; - public string Reference; // ZH 中文原文参考 - public string Translation; // 目标语言翻译内容 + public string EnglishText; // EN 列(翻译标准参考) + public string ReferenceText; // 参考语言列(玩家指定语言,供参考) + public string Translation; // 翻译内容列(玩家填写,空则不替换) } /// /// CSV 读写工具(RFC 4180 兼容,支持引号内换行和转义引号) + /// CSV 格式为 4 列:ID | EN | {referenceLanguage} | Translation /// public static class WorkshopModCsv { /// - /// 将翻译条目列表写为 CSV 内容字符串 + /// 将翻译条目列表写为 CSV 内容字符串(4 列格式) /// - public static string WriteCsv(MultilingualType targetType, List entries) + /// 参考语言类型(第 3 列标题及内容) + /// 翻译条目列表 + public static string WriteCsv(MultilingualType referenceLanguage, List entries) { var sb = new StringBuilder(); - sb.AppendLine($"ID,ZH,{targetType}"); + // 4 列英文标题:ID | EN | {referenceLanguage} | Translation + sb.AppendLine($"ID,EN,{referenceLanguage},Translation"); foreach (var entry in entries) { sb.Append(entry.Id); sb.Append(','); - sb.Append(EscapeField(entry.Reference)); + sb.Append(EscapeField(entry.EnglishText)); sb.Append(','); - sb.Append(EscapeField(entry.Translation)); + sb.Append(EscapeField(entry.ReferenceText)); + sb.Append(','); + sb.Append(EscapeField(entry.Translation)); // 导出时留空,供玩家填写 sb.AppendLine(); } return sb.ToString(); } /// - /// 解析 CSV 内容字符串,返回翻译条目列表 + /// 解析 CSV 内容字符串,返回有效翻译条目列表(仅返回 Translation 列非空的行) /// /// CSV 文本内容 - /// 输出:表头第三列的语言标识 - public static List ReadCsv(string content, out string headerLanguage) + /// 输出:表头第 3 列的语言标识 + public static List ReadCsv(string content, out string referenceLanguage) { - headerLanguage = ""; + referenceLanguage = ""; var result = new List(); if (string.IsNullOrEmpty(content)) return result; @@ -76,26 +83,28 @@ namespace Logic.Multilingual var rows = ParseCsvRows(content); if (rows.Count < 2) return result; - // 解析表头:ID, ZH, {Language} + // 解析表头:ID, EN, {referenceLanguage}, Translation var header = rows[0]; - if (header.Length < 3) return result; - headerLanguage = header[2].Trim(); + if (header.Length < 4) return result; + referenceLanguage = header[2].Trim(); // 解析数据行 for (int i = 1; i < rows.Count; i++) { var row = rows[i]; - if (row.Length < 3) continue; + if (row.Length < 4) continue; var idStr = row[0].Trim(); if (!uint.TryParse(idStr, out uint id)) continue; - var translation = row[2]; + // 只有第 4 列(Translation)非空才生效 + var translation = row[3]; if (string.IsNullOrEmpty(translation)) continue; result.Add(new TranslationEntry { Id = id, - Reference = row[1], + EnglishText = row[1], + ReferenceText = row[2], Translation = translation }); } diff --git a/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModExporter.cs b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModExporter.cs index 053182644..ee40b380f 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModExporter.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModExporter.cs @@ -31,18 +31,18 @@ namespace Logic.Multilingual /// /// 导出标准 Mod 翻译模板到本地磁盘 - /// 按当前玩家选择的语言导出:ID + 中文原文 + 玩家语言文本 /// + /// Mod 目标语言(写入 mod_info,表示此 Mod 修改哪个语种字段) + /// 参考语言(CSV 第 3 列,供玩家翻译时参考) /// Mod 文件夹名称,为空则自动生成 /// 导出成功返回文件夹路径,失败返回 null - public static string ExportModTemplate(string modName = null) + public static string ExportModTemplate(MultilingualType targetLanguage, MultilingualType referenceLanguage, string modName = null) { try { - var currentType = MultilingualManager.Instance.CurrentType; - if (currentType == MultilingualType.None || currentType == MultilingualType.Max) + if (targetLanguage == MultilingualType.None || targetLanguage == MultilingualType.Max) { - LogSystem.LogError("WorkshopModExporter: 当前语言类型无效,无法导出模板"); + LogSystem.LogError("WorkshopModExporter: 目标语言类型无效,无法导出模板"); return null; } @@ -57,12 +57,12 @@ namespace Logic.Multilingual // 生成默认 Mod 文件夹名 if (string.IsNullOrEmpty(modName)) - modName = $"TranslationMod_{currentType}_{DateTime.Now:yyyyMMdd_HHmmss}"; + modName = $"TranslationMod_{targetLanguage}_{DateTime.Now:yyyyMMdd_HHmmss}"; var modPath = Path.Combine(GetModRootPath(), modName); Directory.CreateDirectory(modPath); - // 构建翻译条目列表 + // 构建翻译条目列表(EN 为翻译标准列,referenceLanguage 为参考列,Translation 留空) var entries = new List(); foreach (var item in data.Items) { @@ -71,17 +71,18 @@ namespace Logic.Multilingual entries.Add(new TranslationEntry { Id = item.ID, - Reference = item.ZH ?? "", - Translation = item.GetStrByType(currentType) ?? "" + EnglishText = item.EN ?? "", + ReferenceText = item.GetStrByType(referenceLanguage) ?? "", + Translation = "" // 留空,由玩家填写 }); } // 写入 mod_info.json var modInfo = new WorkshopModInfo { - title = $"{currentType} Translation Mod", + title = $"{targetLanguage} Translation Mod", author = GetSteamPlayerName(), - targetLanguage = currentType.ToString(), + targetLanguage = targetLanguage.ToString(), description = "", version = "1.0" }; @@ -93,7 +94,7 @@ namespace Logic.Multilingual ); // 写入 translation.csv(UTF-8 with BOM,Excel 友好) - var csvContent = WorkshopModCsv.WriteCsv(currentType, entries); + var csvContent = WorkshopModCsv.WriteCsv(referenceLanguage, entries); File.WriteAllText( Path.Combine(modPath, TranslationFileName), csvContent, @@ -101,7 +102,7 @@ namespace Logic.Multilingual ); LogSystem.LogInfo( - $"WorkshopModExporter: Mod 模板已导出到 {modPath},语言={currentType},共 {entries.Count} 条"); + $"WorkshopModExporter: Mod 模板已导出到 {modPath},目标语言={targetLanguage},参考语言={referenceLanguage},共 {entries.Count} 条"); return modPath; } catch (Exception e) @@ -111,6 +112,22 @@ namespace Logic.Multilingual } } + /// + /// 导出标准 Mod 翻译模板(使用当前游戏语言作为参考语言的便捷重载) + /// + /// Mod 文件夹名称,为空则自动生成 + /// 导出成功返回文件夹路径,失败返回 null + public static string ExportModTemplate(string modName = null) + { + var currentType = MultilingualManager.Instance.CurrentType; + if (currentType == MultilingualType.None || currentType == MultilingualType.Max) + { + LogSystem.LogError("WorkshopModExporter: 当前语言类型无效,无法导出模板"); + return null; + } + return ExportModTemplate(currentType, currentType, modName); + } + /// /// 获取 Steam 玩家昵称,未初始化时返回默认值 /// diff --git a/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModLoader.cs b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModLoader.cs index 68f117b09..cb63d419a 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModLoader.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModLoader.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.IO; using Logic.CrashSight; using Steamworks; +using TH1_Logic.Config; using UnityEngine; namespace Logic.Multilingual @@ -18,6 +19,7 @@ namespace Logic.Multilingual { /// /// 加载并应用所有 Mod(先本地后订阅,后加载的覆盖先加载的) + /// 目标语言由各 Mod 的 mod_info.targetLanguage 决定 /// /// 成功应用的翻译条目总数 public static int ApplyAllMods(MultilingualData data) @@ -28,39 +30,69 @@ namespace Logic.Multilingual int count = 0; // 先应用本地 Mod(优先级较低) - count += ApplyModsFromPaths(data, GetLocalModPaths()); + count += ApplyModsFromPaths(data, GetLocalModPaths(), MultilingualType.None); // 再应用订阅的 Workshop Mod(后加载覆盖前加载) - count += ApplyModsFromPaths(data, GetSubscribedModPaths()); + count += ApplyModsFromPaths(data, GetSubscribedModPaths(), MultilingualType.None); return count; } /// - /// 仅应用已订阅的 Workshop Mod + /// 按玩家配置的语种-Mod 优先级设置应用所有 Mod + /// 每个语种对应一个有序 Mod 列表,低索引=低优先级(先应用),高索引=高优先级(后应用可覆盖) + /// + /// 多语言数据 + /// 语种-Mod 配置列表 + /// 成功应用的翻译条目总数 + public static int ApplyModsWithConfig(MultilingualData data, List configs) + { + if (data == null || configs == null) return 0; + data.RefreshDict(); + if (data.ItemDict == null) return 0; + + int total = 0; + foreach (var config in configs) + { + if (config == null || config.ModPaths == null) continue; + // 按索引顺序应用(低索引=低优先级先应用,高索引=高优先级后应用) + total += ApplyModsFromPaths(data, config.ModPaths, config.Language); + } + return total; + } + + /// + /// 仅应用已订阅的 Workshop Mod(目标语言由 mod_info 决定) /// public static int ApplySubscribedMods(MultilingualData data) { if (data == null) return 0; data.RefreshDict(); if (data.ItemDict == null) return 0; - return ApplyModsFromPaths(data, GetSubscribedModPaths()); + return ApplyModsFromPaths(data, GetSubscribedModPaths(), MultilingualType.None); } /// - /// 仅应用本地 Mod + /// 仅应用本地 Mod(目标语言由 mod_info 决定) /// public static int ApplyLocalMods(MultilingualData data) { if (data == null) return 0; data.RefreshDict(); if (data.ItemDict == null) return 0; - return ApplyModsFromPaths(data, GetLocalModPaths()); + return ApplyModsFromPaths(data, GetLocalModPaths(), MultilingualType.None); } /// /// 应用指定文件夹中的单个 Mod /// + /// 多语言数据 + /// Mod 文件夹路径 + /// + /// 强制覆盖目标语言(None = 使用 mod_info.targetLanguage) + /// 玩家可通过优先级配置将任意 Mod 应用到任意语种 + /// /// 成功覆盖的翻译条目数量 - public static int ApplyModFromFolder(MultilingualData data, string folderPath) + public static int ApplyModFromFolder(MultilingualData data, string folderPath, + MultilingualType targetLanguageOverride = MultilingualType.None) { if (data == null || string.IsNullOrEmpty(folderPath)) return 0; data.RefreshDict(); @@ -86,13 +118,21 @@ namespace Logic.Multilingual return 0; } - // 2. 校验目标语言 - var targetType = ParseLanguageType(modInfo.targetLanguage); - if (targetType == MultilingualType.None || targetType == MultilingualType.Max) + // 2. 确定目标语言(优先使用外部覆盖,其次使用 mod_info 声明) + MultilingualType targetType; + if (targetLanguageOverride != MultilingualType.None && targetLanguageOverride != MultilingualType.Max) { - LogSystem.LogError( - $"WorkshopModLoader: 无效的目标语言 \"{modInfo.targetLanguage}\" - {folderPath}"); - return 0; + targetType = targetLanguageOverride; + } + else + { + targetType = ParseLanguageType(modInfo.targetLanguage); + if (targetType == MultilingualType.None || targetType == MultilingualType.Max) + { + LogSystem.LogError( + $"WorkshopModLoader: 无效的目标语言 \"{modInfo.targetLanguage}\" - {folderPath}"); + return 0; + } } // 3. 读取 translation.csv @@ -114,17 +154,9 @@ namespace Logic.Multilingual return 0; } - var entries = WorkshopModCsv.ReadCsv(csvContent, out string headerLanguage); + var entries = WorkshopModCsv.ReadCsv(csvContent, out string referenceLanguage); - // 校验 CSV 表头语言与 mod_info 一致性 - if (!string.IsNullOrEmpty(headerLanguage) && - !string.Equals(headerLanguage, modInfo.targetLanguage, StringComparison.OrdinalIgnoreCase)) - { - LogSystem.LogWarning( - $"WorkshopModLoader: CSV 表头语言({headerLanguage})与 mod_info 目标语言({modInfo.targetLanguage})不一致,以 mod_info 为准"); - } - - // 4. 逐条覆盖多语言数据 + // 4. 逐条覆盖多语言数据(仅 Translation 列非空的行生效) int appliedCount = 0; foreach (var entry in entries) { @@ -136,7 +168,7 @@ namespace Logic.Multilingual if (appliedCount > 0) { LogSystem.LogInfo( - $"WorkshopModLoader: 已应用 Mod \"{modInfo.title}\" (语言={targetType}),覆盖 {appliedCount}/{entries.Count} 条翻译"); + $"WorkshopModLoader: 已应用 Mod \"{modInfo.title}\" (目标语言={targetType},参考语言={referenceLanguage}),覆盖 {appliedCount}/{entries.Count} 条翻译"); } return appliedCount; @@ -197,12 +229,22 @@ namespace Logic.Multilingual return paths; } - private static int ApplyModsFromPaths(MultilingualData data, List paths) + /// + /// 获取所有可用的 Mod 路径(本地 + 订阅) + /// + public static List GetAllAvailableModPaths() + { + var paths = new List(GetLocalModPaths()); + paths.AddRange(GetSubscribedModPaths()); + return paths; + } + + private static int ApplyModsFromPaths(MultilingualData data, List paths, MultilingualType targetOverride) { int totalCount = 0; foreach (var path in paths) { - totalCount += ApplyModFromFolder(data, path); + totalCount += ApplyModFromFolder(data, path, targetOverride); } return totalCount; } @@ -222,15 +264,16 @@ namespace Logic.Multilingual { switch (type) { - case MultilingualType.ZH: item.ZH = value; break; - case MultilingualType.TDZH: item.TDZH = value; break; - case MultilingualType.EN: item.EN = value; break; - case MultilingualType.JP: item.JP = value; break; - case MultilingualType.KR: item.KR = value; break; - case MultilingualType.RU: item.RU = value; break; - case MultilingualType.ES: item.ES = value; break; - case MultilingualType.PT: item.PT = value; break; - case MultilingualType.FR: item.FR = value; break; + case MultilingualType.ZH: item.ZH = value; break; + case MultilingualType.TDZH: item.TDZH = value; break; + case MultilingualType.EN: item.EN = value; break; + case MultilingualType.JP: item.JP = value; break; + case MultilingualType.KR: item.KR = value; break; + case MultilingualType.RU: item.RU = value; break; + case MultilingualType.ES: item.ES = value; break; + case MultilingualType.PT: item.PT = value; break; + case MultilingualType.FR: item.FR = value; break; + case MultilingualType.Custom: item.Custom = value; break; } } }