diff --git a/MD/Workshop_Multilingual_Design.md b/MD/Workshop_Multilingual_Design.md index 0d687bfa3..69a0e7ba4 100644 --- a/MD/Workshop_Multilingual_Design.md +++ b/MD/Workshop_Multilingual_Design.md @@ -1,261 +1,137 @@ -# TH1 创意工坊多语言共创设计文档 +# TH1 创意工坊多语言 Mod 设计与工作流文档 -## 1. 架构与目标概述 -基于 TH1 现有的 ScriptableObject 多语言框架,本设计方案旨在实现**“现有语言的社区翻译替换”**。 -系统**不新增**任何 `MultilingualType` 枚举,也**不涉及**动态字体的引入。核心目标是允许玩家导出游戏内现有的多语言基础数据作为标准模板,玩家在模板上完成翻译后打包成 Mod 上传至创意工坊。游戏在运行时读取这些 Mod 数据,并动态替换/覆盖 `MultilingualData.asset` 内存中对应的语言字段。 +## 1. 核心设计原则与边界 + +基于 TH1 现有的多语言系统架构,本创意工坊方案的设计核心为**“现有语种的局部文本替换”**。为了保证系统的稳定性和玩家创作的低门槛,明确以下设计边界: + +1. **仅替换,不新增**:多语言 Mod 仅用于修改和润色游戏中已存在的语言(如 ZH, EN, JP 等),不支持通过 Mod 新增全新的语言枚举。但系统提供了一个特殊的 **Custom**(自定义)语种,专供 Mod 应用时使用——它不参与游戏本体的多语言流程,只作为 Mod 目标语言的一个额外选项。 +2. **无需处理字体**:因为不新增语种,所有 Mod 的显示将直接复用游戏本体中该语言对应的原有字体(TMP_FontAsset)以及排版配置,创作者完全不需要考虑字体渲染和缺失问题。Custom 语种回退到 EN 字体。 +3. **支持增量/部分替换**:Mod 允许只修改一部分文本。玩家不需要翻译游戏内的所有内容,翻译了哪几条,游戏运行时就仅替换那几条,未修改的内容保持游戏默认文本。 +4. **多 Mod 优先级处理**:玩家可以为每个语种单独配置一组 Mod 列表及其优先级。低优先级 Mod 先应用,高优先级 Mod 后应用(覆盖低优先级)。Mod 本身不绑定语种——任意 Mod 可以被分配给任意语种。此配置存储在 `GameConfig.ModLanguageConfigs` 中,由 UI 或编辑器面板负责设置。 +5. **Mod 与语种解耦**:导出模板时,玩家可以分别指定"目标语种"(Mod 最终要修改的语种)和"参考语种"(CSV 中显示的参照文本列)。mod_info 记录创作者声明的目标语种,但在应用阶段可以被玩家的优先级配置覆盖。 --- -## 2. 玩家共创工作流总览 +## 2. 玩家共创工作流(Modder 视角) -1. **获取模板**:玩家在游戏内点击“导出翻译模板”工具,获取所有可翻译内容的标准对照表(包含 ID 和原文)。 -2. **本地翻译**:玩家在本地使用 Excel 或记事本,将目标语言的翻译内容填入标准文件中。 -3. **制作并上传工坊**:玩家在游戏内的 Mod 制作工具中,填写 Mod 元信息(如替换哪种语言、标题等)并选择本地文件夹,一键上传至 Steam 创意工坊。 -4. **游戏应用读取**:其他玩家订阅该 Mod 后,游戏在启动时通过 Steam API 读取本地文件,并在内存中覆盖对应的语言文本。 +对于想要参与多语言优化的创作者,整体流程分为三个简单的步骤,所有操作均设计为“零门槛”。 + +### 步骤一:一键获取 Mod 源文件 +玩家无需寻找游戏目录或解包游戏,在游戏客户端内点击**"获取翻译模板"**按钮,并指定**目标语种**(要修改的语言)和**参考语种**(CSV 中显示的参照列)。 +系统会自动在本地生成标准化的 Mod 源文件文件夹,包含所有可翻译内容的对照表及 Mod 配置文件。 + +### 步骤二:本地编辑翻译内容 +玩家打开生成的本地文件夹,找到对应的翻译表格文件(CSV 格式)。 +表格共 4 列: +* **ID**:游戏内多语言系统分配的唯一标识符(不可修改)。 +* **EN**:英文文本,作为翻译标准参考。 +* **{参考语种}**:玩家指定的参考语言列,供对照(修改无效)。 +* **Translation**:创作者填写的翻译内容。**只有此列有内容的行才会在游戏中生效替换;留空则保持原文。** + +玩家只需使用 Excel 或任意文本编辑器,将润色后的文本填入 Translation 列即可。 + +### 步骤三:上传至创意工坊 +玩家完成本地编辑,并在文件夹内准备好一张预览图(封面图)后,回到游戏客户端中。 +点击**"上传 Mod"**按钮,在游戏内 UI 中选择刚刚编辑好的文件夹,填写好 Mod 的名称和简介,即可一键打包并上传到 Steam 创意工坊,供其他玩家订阅。“主菜单”或“Mod 菜单”中)点击**“获取翻译模板”**按钮。 + +### 步骤三:上传至创意工坊 +表格中会清晰地列出每条文本的**“唯一 ID”**、**“原文参考”**以及留空的**“翻译内容列”**。 +玩家只需要使用 Excel 或任意文本编辑器,将自己润色或修改后的文本填入“翻译内容列”即可。不想修改的条目直接留空,系统会自动忽略。 + +### 步骤三:上传至创意工坊 +玩家完成本地编辑,并在文件夹内准备好一张预览图(封面图)后,回到游戏客户端中。 +点击**“上传 Mod”**按钮,在游戏内 UI 中选择刚刚编辑好的文件夹,填写好 Mod 的名称和简介,即可一键打包并上传到 Steam 创意工坊,供其他玩家订阅。 --- -## 3. Mod 数据格式设计 +## 3. 玩家使用工作流(订阅者视角) -为了降低翻译人员的门槛,建议采用对 Excel 最友好的 **CSV** 格式存储核心翻译数据,并搭配一个简单的 **JSON** 存储 Mod 元数据。 +对于普通的玩家,体验多语言 Mod 的流程完全无感化: -### 3.1 玩家本地 Mod 文件夹结构 -```text -MyLanguageMod/ - ├── mod_info.json // Mod的元数据(说明这个Mod替换的是哪种语言) - ├── translation.csv // 核心翻译对照表 - └── preview.png // 创意工坊封面图 (必须<1MB) -``` - -### 3.2 `mod_info.json` 格式 -指明该 Mod 针对的是哪种枚举类型的语言替换: -```json -{ - "ModTitle": "更地道的英文翻译", - "TargetLanguage": "EN", - "Author": "PlayerName", - "Description": "优化了部分机翻的英文描述" -} -``` -*注:`TargetLanguage` 必须严格对应 `MultilingualType` 中的枚举名(如 ZH, EN, JP, RU 等)。* - -### 3.3 `translation.csv` 格式 -建议包含三列:`ID`, `Source` (中文或英文原文参考), `Translation` (玩家填写的翻译)。 -```csv -ID,Source,Translation -1,"开始游戏","Start Game" -2,"设置","Settings" -3,"获得了 **<42>**","Got **<42>**" -``` -*注:玩家直接复制原有的 `****` 即可,系统原有的运行时解析逻辑(`ResolveEmbeddedStringsRunning`)完全不受影响。* +1. **订阅 Mod**:玩家在 Steam 社区的 TH1 创意工坊页面浏览,点击“订阅”心仪的翻译优化 Mod。 +2. **自动生效**:Steam 客户端会自动下载文件。玩家启动游戏后,无需任何额外配置,游戏内对应语言的文本就已经被自动替换为 Mod 中的版本。 --- -## 4. 游戏内 Mod 工具开发详情 +## 4. Mod 文件结构与数据定义 -因为玩家无法使用 Unity Editor,必须在**游戏客户端运行时**(如主菜单或设置界面,或者做一个独立的"Mod制作工具"场景)提供以下核心功能按钮: +一个标准的多语言 Mod 文件夹在本地包含以下三个核心文件: -### 4.1 功能一:导出本地化模板 (Export Template) -当玩家想要制作翻译 Mod 时,点击此按钮,游戏在硬盘(如 `Application.persistentDataPath + "/ModTemplate"` 或玩家自定义目录)生成所需的文件。 +1. **mod_info 配置** + 用于声明该 Mod 的基础元数据,主要包括:Mod 的标题、作者,以及该 Mod 创作者声明的目标替换语种(如 EN、ZH、Custom 等)。注意:在应用阶段,玩家可通过优先级配置将任意 Mod 用于任意语种,覆盖此声明。 -**程序实现逻辑(伪代码思路):** -```csharp -public void ExportTranslationTemplate(string exportFolderPath) -{ - // 1. 生成基础的 mod_info.json - string modInfo = "{\n \"ModTitle\": \"My Translation Mod\",\n \"TargetLanguage\": \"EN\",\n \"Author\": \"\",\n \"Description\": \"\"\n}"; - File.WriteAllText(Path.Combine(exportFolderPath, "mod_info.json"), modInfo); +2. **translation 翻译数据表(CSV 格式,4 列)** + 这是 Mod 的核心数据载体。表结构如下: - // 2. 遍历内存字典,生成 CSV (使用 StringBuilder) - StringBuilder csv = new StringBuilder(); - csv.AppendLine("ID,Source,Translation"); - - // 获取单例中的字典 - var dict = MultilingualManager.Instance.ItemDict; // 注意:需要将 ItemDict 暴露出来或提供遍历接口 - - // 排序保证一致性 - var sortedKeys = dict.Keys.ToList(); - sortedKeys.Sort(); - - foreach (var id in sortedKeys) - { - var item = dict[id]; - // 跳过不需要导出的文本 (如果有相关标记) - if(item.IsDeprecated) continue; - - string zhStr = item.ZH ?? ""; - // 处理 CSV 中的双引号转义 - zhStr = zhStr.Replace("\"", "\"\""); - - csv.AppendLine($"{id},\"{zhStr}\",\"\""); - } - - File.WriteAllText(Path.Combine(exportFolderPath, "translation.csv"), csv.ToString(), Encoding.UTF8); - - // 提示玩家导出成功,并打开对应文件夹 (Application.OpenURL(exportFolderPath)) -} -``` + | 列名 | 说明 | + |--------------|----------------------------------------------------| + | ID | 游戏内唯一标识符(不可修改) | + | EN | 英文原文,作为翻译标准参考(修改无效) | + | {参考语种} | 玩家导出时指定的参考语言原文(修改无效) | + | Translation | 创作者填写的翻译内容。**非空才生效**,空则跳过 | -### 4.2 功能二:上传至创意工坊 (Upload to Workshop) -提供一个简单的 UI 界面,让玩家选择自己修改好的文件夹路径,然后调用 Steamworks API 上传。 + 示例(目标 ZH,参考 EN): + ``` + ID,EN,EN,Translation + item_sword_name,Iron Sword,Iron Sword,铁剑 + item_shield_name,Wooden Shield,Wooden Shield, + ``` -**程序实现逻辑简述 (以 Steamworks.NET 为例):** -1. **校验**:检查目标文件夹中是否存在 `mod_info.json`、`translation.csv` 和 `preview.png`。 -2. **解析元数据**:读取 `mod_info.json`,获取 `ModTitle`、`Description`。 -3. **创建物品**:调用 `SteamUGC.CreateItem(SteamUtils.GetAppID(), EWorkshopFileType.k_EWorkshopFileTypeCommunity)`。 -4. **提交更新**:在创建成功的回调中,使用返回的 `PublishedFileId_t`: - - `UGCUpdateHandle_t handle = SteamUGC.StartItemUpdate(AppID, FileId);` - - `SteamUGC.SetItemTitle(handle, title);` - - `SteamUGC.SetItemDescription(handle, desc);` - - `SteamUGC.SetItemContent(handle, folderPath);` // 设置文件夹路径 - - `SteamUGC.SetItemPreview(handle, previewImagePath);` // 设置封面图绝对路径 - - `SteamUGC.SubmitItemUpdate(handle, "Initial upload");` +3. **preview 封面图** + 一张用于展示在 Steam 创意工坊列表中的预览图片。 --- -## 5. 运行时读取与内存覆盖逻辑 (核心) +## 5. 运行时读取与覆盖流程 -玩家订阅了 Mod 后,Steam 客户端会自动下载文件。游戏在启动时需要去 Steam 目录读取这些替换文件,并**强行修改** `MultilingualData` 在内存中的字典数据。 - -### 5.1 修改 `MultilingualManager.cs` 的初始化流程 -在 `Init()` 方法中,调用原生数据加载后,紧接着调用 Mod 加载逻辑。 -```csharp -public void Init() -{ - RefreshMultilingualData(); - - // ========= 新增:读取创意工坊 Mod 并替换内存数据 ========= - // 必须在 ChangedMultilingual 之前调用,以确保 UI 刷新时使用的是最新数据 - LoadAllSubscribedLanguageMods(); - // ======================================================= - - _currentType = ConfigManager.Instance.Config.MultilingualType; - if (_currentType == MultilingualType.None) _currentType = GetSystemLanguage(); - ChangedMultilingual(_currentType); -} +``` +游戏启动 + ↓ +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 文本 ``` -### 5.2 获取 Steam 订阅的 Mod 路径 -```csharp -private List GetSteamWorkshopModPaths() -{ - List paths = new List(); - if (!SteamManager.Initialized) return paths; // 确保 Steam 已初始化 - - uint numSubscribed = SteamUGC.GetNumSubscribedItems(); - PublishedFileId_t[] subscribedItems = new PublishedFileId_t[numSubscribed]; - uint count = SteamUGC.GetSubscribedItems(subscribedItems, numSubscribed); - - for (uint i = 0; i < count; i++) - { - ulong punSizeOnDisk; - string folderPath; - uint punTimeStamp; - // 获取该 Mod 在本地的绝对路径 - if (SteamUGC.GetItemInstallInfo(subscribedItems[i], out punSizeOnDisk, out folderPath, 1024, out punTimeStamp)) - { - paths.Add(folderPath); - } - } - return paths; -} -``` - -### 5.3 `LoadAllSubscribedLanguageMods()` 的解析与覆盖实现 -遍历获取到的 Mod 路径,解析 CSV,并覆盖 `MultilingualItem` 对应的字段: - -```csharp -private void LoadAllSubscribedLanguageMods() -{ - List modPaths = GetSteamWorkshopModPaths(); - - foreach (string path in modPaths) - { - string infoPath = Path.Combine(path, "mod_info.json"); - string csvPath = Path.Combine(path, "translation.csv"); - - if (!File.Exists(infoPath) || !File.Exists(csvPath)) continue; - - // 1. 解析目标替换语言 - string infoJson = File.ReadAllText(infoPath); - // 使用简单的包装类反序列化 - ModInfoWrapper modInfo = JsonUtility.FromJson(infoJson); - - if (!Enum.TryParse(modInfo.TargetLanguage, out MultilingualType targetType)) - { - Debug.LogError($"Mod [{modInfo.ModTitle}] 填写的语言类型不合法: " + modInfo.TargetLanguage); - continue; - } - - // 2. 解析 CSV 并覆盖字典 - // 注:此处需要实现或引入一个标准的 CSV 解析器(处理引号和换行),不要用简单的 string.Split(',') - List rows = CSVParser.Parse(File.ReadAllText(csvPath, Encoding.UTF8)); - - // 假设第一行是表头,从第二行开始遍历 - for (int i = 1; i < rows.Count; i++) - { - var row = rows[i]; - if (row.Length < 3) continue; // 确保有 ID, Source, Translation 三列 - - if (uint.TryParse(row[0], out uint id)) - { - string translation = row[2]; // 玩家填写的翻译文本 - if (string.IsNullOrEmpty(translation)) continue; - - if (_multilingualData.ItemDict.TryGetValue(id, out var item)) - { - // 将翻译内容覆盖到对应的枚举字段上 - OverwriteItemLanguage(item, targetType, translation); - } - } - } - - Debug.Log($"成功加载多语言 Mod: {modInfo.ModTitle},替换语言: {targetType}"); - } -} -``` - -### 5.4 `OverwriteItemLanguage` 字段覆盖辅助函数 -因为 `MultilingualItem` 中各语言是分开的公有字段(ZH, EN, JP 等),我们需要根据枚举类型将玩家文本强行写入: - -```csharp -private void OverwriteItemLanguage(MultilingualItem item, MultilingualType type, string text) -{ - switch (type) - { - case MultilingualType.ZH: item.ZH = text; break; - case MultilingualType.TDZH: item.TDZH = text; break; - case MultilingualType.EN: item.EN = text; break; - case MultilingualType.JP: item.JP = text; break; - case MultilingualType.KR: item.KR = text; break; - case MultilingualType.RU: item.RU = text; break; - case MultilingualType.ES: item.ES = text; break; - case MultilingualType.PT: item.PT = text; break; - case MultilingualType.FR: item.FR = text; break; - } -} - -// 辅助数据结构 -[Serializable] -private class ModInfoWrapper -{ - public string ModTitle; - public string TargetLanguage; - public string Author; - public string Description; -} -``` +**关于嵌套引用的兼容性:**“翻译内容”不为空,则在内存中找到对应 ID 的多语言数据节点。 +**关于嵌套引用的兼容性:** +原系统的 `****` 专有名词嵌套引用机制与 Mod 系统完全兼容。只要玩家在 Translation 列保留了 `****` 格式,运行时底层仍会正常触发解析,无需任何特殊处理。 --- -## 6. 特殊情况与边界处理说明 +## 6. Workshop 浏览器(编辑器功能) -1. **多次覆盖优先级 (Mod 冲突)**: - 如果玩家同时订阅了两个都是修改 `TargetLanguage: "EN"` 的 Mod,由于循环读取顺序的问题,后读取的 Mod 会覆盖前面 Mod 的相同 ID。这属于正常的 Mod 冲突逻辑(Last Load Wins)。未来可以考虑在游戏 UI 中添加“Mod 排序”功能来决定加载顺序。 +由于游戏未正式发售导致创意工坊页面无法直接在 Steam 客户端访问,编辑器内提供了 **Workshop 浏览器标签页**(⑤ 工坊浏览): -2. **嵌套引用 `****` 的对齐问题**: - 这是本方案最优雅的地方。由于这是运行时纯文本替换,我们直接替换了 `item.EN` 的字符串值。 - 玩家在 CSV 里手写诸如 `Got **<42>**`,而原有的 `MultilingualManager.GetMultilingualText(id)` 在获取到替换后的字符串后,会自动调用 `_multilingualData.ResolveEmbeddedStringsRunning()`,将 `**<42>**` 正常解析成带颜色和图标的富文本。因此,你的**嵌套机制完美兼容此覆盖方案,无需做任何核心逻辑改动**。 +- **查询**:分页查询当前 AppId 对应创意工坊中的所有 Mod(通过 `SteamUGC.CreateQueryAllUGCRequest`)。 +- **订阅/取消订阅**:直接在编辑器内操作,Steam 客户端自动处理下载/删除。 +- **状态显示**:显示每个 Mod 的订阅状态、安装状态、本地路径、标签、投票数等信息。 +- **驱动机制**:编辑器通过 `EditorApplication.update` 定期调用 `SteamAPI.RunCallbacks()` 驱动 Steamworks 异步回调,无需运行游戏。 -3. **字体与排版配置完全兼容**: - 因为本方案**只替换现有枚举的数据内容**(例如把原本的机翻 EN 替换成玩家润色的 EN),所以原本绑定给 `MultilingualType.EN` 的 `TMP_FontAsset`(`MultilingualFontGroup.ENFont`)以及 UI 上的排版参数配置(`MultiTextConfig`)将继续完美生效,完全不涉及字体更换和排版错乱的问题。 +核心类:`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 new file mode 100644 index 000000000..809f807a4 --- /dev/null +++ b/Unity/Assets/Editor/WorkshopModEditorWindow.cs @@ -0,0 +1,971 @@ +/* + * @Author: 白哉 + * @Description: 创意工坊多语言 Mod 编辑器测试窗口(全功能测试面板) + * @Date: 2026年04月14日 星期二 15:43:02 + * @Modify: 2026年04月16日 - 4列CSV/Custom语种/Mod优先级配置/Workshop浏览器 + */ + +using System; +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, ModConfig, Browse, CsvTest, Inspect } + private Tab _currentTab = Tab.Export; + private readonly string[] _tabNames = + { "① 导出模板", "② 上传Workshop", "③ Mod配置", "④ 工坊浏览", "⑤ CSV测试", "⑥ 数据检视" }; + + // ── 导出面板状态 ── + 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 _uploadDescription = ""; + private string _uploadPreviewPath = ""; + 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; // 直接操作游戏真实序列化配置 + private string _configFilePath; // game_cfg.json 文件路径 + + // ── Workshop 浏览器状态 ── + private Vector2 _browseScroll; + private string _browseStatus = ""; + private bool _browseSteamOk; + + // ── CSV 测试状态 ── + private MultilingualType _csvTestRefLang = MultilingualType.ZH; + private string _csvTestInput = ""; + private string _csvTestOutput = ""; + private Vector2 _csvInputScroll; + private Vector2 _csvOutputScroll; + + // ── 数据检视状态 ── + private string _inspectFolder = ""; + private string _inspectResult = ""; + private Vector2 _inspectScroll; + private uint _inspectSearchId; + private string _inspectSearchResult = ""; + + // ── 通用 ── + private Vector2 _mainScroll; + + [MenuItem("TH1工具/创意工坊多语言 Mod 测试面板")] + public static void ShowWindow() + { + var window = GetWindow("Workshop Mod 测试"); + window.minSize = new Vector2(680, 520); + } + + private void OnEnable() + { + LoadGameConfig(); + // 注册 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() + { + // 轮询上传器的异步 API 调用 + if (WorkshopModUploader.Instance.IsBusy) + { + WorkshopModUploader.Instance.Poll(); + Repaint(); + } + + // 浏览器相关回调(浏览器的 Subscribe/Unsubscribe 仍使用 CallResult,需要 RunCallbacks 驱动) + bool browserBusy = WorkshopModBrowser.Instance.IsQuerying + || WorkshopModBrowser.Instance.IsSubscribeOperating; + if (browserBusy || _currentTab == Tab.Browse) + { + try + { + SteamAPI.RunCallbacks(); + } + catch (Exception e) + { + Debug.LogWarning($"SteamAPI.RunCallbacks 异常: {e.Message}"); + } + if (browserBusy) 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.ModConfig:DrawModConfigTab(); break; + case Tab.Browse: DrawBrowseTab(); break; + case Tab.CsvTest: DrawCsvTestTab(); break; + case Tab.Inspect: DrawInspectTab(); break; + } + EditorGUILayout.EndScrollView(); + } + + // ═══════════════════════════════════════════════════════════ + // ① 导出模板 + // ═══════════════════════════════════════════════════════════ + private void DrawExportTab() + { + EditorGUILayout.LabelField("导出 Mod 翻译模板", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "导出标准 Mod 文件夹:mod_info.json + translation.csv(4列格式)\n" + + "CSV 列:ID | EN(英文标准) | 参考语言文本 | Translation(玩家填写,留空不替换)\n" + + "目标语言:决定此 Mod 修改哪个语种字段(写入 mod_info)\n" + + "参考语言:CSV 第3列内容,辅助玩家翻译时参考", + MessageType.Info); + + EditorGUILayout.Space(4); + _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); + + 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)) + { + EditorGUILayout.Space(4); + EditorGUILayout.HelpBox($"✅ 导出成功:{_lastExportPath}", MessageType.None); + if (GUILayout.Button("在文件浏览器中打开")) + EditorUtility.RevealInFinder(_lastExportPath); + } + } + + private void DoExport() + { + var modName = string.IsNullOrEmpty(_exportModName) ? null : _exportModName; + var result = WorkshopModExporter.ExportModTemplate(_exportTargetLanguage, _exportRefLanguage, modName); + if (string.IsNullOrEmpty(result)) + { + EditorUtility.DisplayDialog("导出失败", "导出失败,请查看 Console 日志", "确定"); + return; + } + _lastExportPath = result; + Debug.Log($"✅ Mod 模板导出完成:{result},目标={_exportTargetLanguage},参考={_exportRefLanguage}"); + } + + // ═══════════════════════════════════════════════════════════ + // ② 上传到 Workshop + // ═══════════════════════════════════════════════════════════ + private void DrawUploadTab() + { + EditorGUILayout.LabelField("上传 Mod 到 Steam 创意工坊", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "需要 Steam 客户端正在运行且已登录。\n" + + "选择已导出/编辑好的 Mod 文件夹,填写信息后上传。", + MessageType.Info); + + EditorGUILayout.Space(4); + + EditorGUILayout.BeginHorizontal(); + _uploadFolder = EditorGUILayout.TextField("Mod 文件夹", _uploadFolder); + if (GUILayout.Button("浏览...", GUILayout.Width(60))) + { + var selected = EditorUtility.OpenFolderPanel("选择 Mod 文件夹", + WorkshopModExporter.GetModRootPath(), ""); + if (!string.IsNullOrEmpty(selected)) _uploadFolder = selected; + } + 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)); + + if (folderValid) + { + EditorGUILayout.LabelField(" mod_info.json", hasModInfo ? "✅ 存在" : "❌ 缺失"); + EditorGUILayout.LabelField(" translation.csv", hasCsv ? "✅ 存在" : "❌ 缺失"); + } + + EditorGUILayout.Space(4); + _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"); + if (!string.IsNullOrEmpty(selected)) _uploadPreviewPath = selected; + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(8); + + bool isBusy = WorkshopModUploader.Instance.IsBusy; + using (new EditorGUI.DisabledScope(!hasModInfo || !hasCsv || isBusy)) + { + if (GUILayout.Button(isBusy ? "上传中..." : "创建并上传到 Workshop", GUILayout.Height(32))) + DoUpload(); + } + + // 实时显示上传器状态 + if (isBusy) + { + EditorGUILayout.Space(4); + EditorGUILayout.HelpBox(WorkshopModUploader.Instance.StatusMessage, MessageType.None); + } + + if (!string.IsNullOrEmpty(_uploadStatus)) + { + EditorGUILayout.Space(4); + EditorGUILayout.HelpBox(_uploadStatus, MessageType.None); + } + } + + private void DoUpload() + { + _uploadStatus = ""; + WorkshopModUploader.Instance.CreateAndUploadMod( + _uploadFolder, _uploadTitle, _uploadDescription, _uploadPreviewPath, + (success, fileId) => + { + _uploadStatus = success + ? $"✅ 上传成功!PublishedFileId = {fileId}" + : $"❌ 上传失败: {WorkshopModUploader.Instance.StatusMessage}"; + Repaint(); + }); + } + + // ═══════════════════════════════════════════════════════════ + // ③ Mod 配置(读写游戏真实 GameConfig) + // ═══════════════════════════════════════════════════════════ + + private string GetGameConfigPath() + { + return Application.persistentDataPath + "/../Config/game_cfg.json"; + } + + private void LoadGameConfig() + { + _configFilePath = GetGameConfigPath(); + if (File.Exists(_configFilePath)) + { + try + { + string json = File.ReadAllText(_configFilePath); + _editorConfig = JsonUtility.FromJson(json); + if (_editorConfig != null) + { + _editorConfig.MarkSaved(); + _configStatus = $"✅ 已从 {_configFilePath} 加载配置"; + return; + } + } + catch (Exception e) + { + Debug.LogError($"[WorkshopModEditor] 读取 GameConfig 失败: {e.Message}"); + } + } + _editorConfig = new GameConfig(); + _configStatus = File.Exists(_configFilePath) + ? "⚠ 读取配置文件失败,已使用默认配置" + : "ℹ 配置文件不存在,已使用默认配置"; + } + + private void SaveGameConfig() + { + try + { + string dir = Path.GetDirectoryName(_configFilePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + string json = JsonUtility.ToJson(_editorConfig, true); + File.WriteAllText(_configFilePath, json, System.Text.Encoding.UTF8); + _editorConfig.MarkSaved(); + _configStatus = $"✅ 配置已保存到 {_configFilePath}"; + Debug.Log($"[WorkshopModEditor] GameConfig 已保存: {_configFilePath}"); + } + catch (Exception e) + { + _configStatus = $"❌ 保存失败: {e.Message}"; + Debug.LogError($"[WorkshopModEditor] 保存 GameConfig 失败: {e.Message}"); + } + } + + private void DrawModConfigTab() + { + EditorGUILayout.LabelField("Mod 配置(游戏真实 GameConfig)", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "直接读写游戏的 game_cfg.json 配置文件。\n" + + "为每个语种指定使用哪些 Mod 并设置优先级顺序,列表越靠下优先级越高。\n" + + "修改后点击「保存配置」写入文件,点击「应用 Mod」立即生效到多语言数据。", + MessageType.Info); + + // 配置文件路径 & 操作按钮 + EditorGUILayout.Space(4); + EditorGUILayout.LabelField("配置文件", _configFilePath, EditorStyles.wordWrappedLabel); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("重新加载配置", GUILayout.Width(100))) + LoadGameConfig(); + + var changedColor = _editorConfig.IsChanged ? Color.yellow : GUI.color; + var origColor = GUI.color; + GUI.color = changedColor; + if (GUILayout.Button(_editorConfig.IsChanged ? "● 保存配置 *" : "保存配置", GUILayout.Width(100))) + SaveGameConfig(); + GUI.color = origColor; + + if (GUILayout.Button("应用 Mod 到多语言数据", GUILayout.Width(160))) + DoApplyByConfig(); + EditorGUILayout.EndHorizontal(); + + 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 info = WorkshopModExporter.ReadModInfo(path); + if (info == null) return ""; + return $" [{info.targetLanguage}] {info.title}"; + } + + private MultilingualData LoadMultilingualData(out string error) + { + error = ""; + var data = Resources.Load("Export/Multilingual"); + if (data == null) error = "❌ 无法加载 Export/Multilingual"; + return data; + } + + private void DoApplyByConfig() + { + var data = LoadMultilingualData(out string err); + if (data == null) { _configStatus = err; return; } + int count = WorkshopModLoader.ApplyModsWithConfig(data, _editorConfig.ModLanguageConfigs); + _configStatus = $"✅ 按优先级配置应用完成,共覆盖 {count} 条翻译"; + } + + // ═══════════════════════════════════════════════════════════ + // ④ Workshop 浏览器 + // ═══════════════════════════════════════════════════════════ + private void DrawBrowseTab() + { + EditorGUILayout.LabelField("Steam 创意工坊浏览器", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "查询当前 AppId 下的所有 Workshop Mod,可在编辑器中直接订阅/取消订阅。\n" + + "需要 Steam 客户端正在运行且已登录。已订阅且已安装的 Mod 可在③中配置并应用。", + MessageType.Info); + + EditorGUILayout.Space(4); + + // 检测 Steam 状态 + bool steamOk = false; + try { steamOk = SteamAPI.IsSteamRunning(); } catch { } + _browseSteamOk = steamOk; + + EditorGUILayout.LabelField("Steam 状态", steamOk ? "✅ 运行中" : "❌ 未运行"); + + if (!steamOk) + { + 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(); + EditorGUILayout.EndHorizontal(); + + if (!string.IsNullOrEmpty(_csvTestOutput)) + { + EditorGUILayout.Space(4); + EditorGUILayout.LabelField("解析结果:"); + _csvOutputScroll = EditorGUILayout.BeginScrollView(_csvOutputScroll, GUILayout.Height(180)); + EditorGUILayout.TextArea(_csvTestOutput, EditorStyles.wordWrappedLabel, GUILayout.ExpandHeight(true)); + EditorGUILayout.EndScrollView(); + } + } + + private void RunCsvRoundtripTest() + { + var sb = new StringBuilder(); + sb.AppendLine("══════ CSV 4列回环测试 ══════"); + + // 构造含特殊字符的测试数据(Translation 非空才会被读出) + var entries = new List + { + 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="" }, // 应被跳过 + }; + + var csv = WorkshopModCsv.WriteCsv(_csvTestRefLang, entries); + sb.AppendLine("[生成的 CSV]:"); + sb.AppendLine(csv); + + var parsed = WorkshopModCsv.ReadCsv(csv, out string refLang); + sb.AppendLine($"[解析结果] 参考语言: {refLang}, 有效条目数: {parsed.Count}"); + + 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}: 缺失"); + failed++; + continue; + } + var actual = parsed[i]; + 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++; + } + else + { + sb.AppendLine($" ❌ ID={expected.Id}: 失败"); + 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(" ✅ 空 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 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; } + + var entries = WorkshopModCsv.ReadCsv(_csvTestInput, out string refLang); + var sb = new StringBuilder(); + sb.AppendLine($"参考语言(第3列): {refLang}"); + sb.AppendLine($"有效条目数(Translation非空): {entries.Count}"); + sb.AppendLine("---"); + foreach (var e in entries) + { + sb.AppendLine($"ID={e.Id} EN=\"{Escape(e.EnglishText)}\" Ref=\"{Escape(e.ReferenceText)}\" Translation=\"{Escape(e.Translation)}\""); + } + _csvTestOutput = sb.ToString(); + } + + private string GenerateSampleCsv() + { + // 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.Space(4); + EditorGUILayout.LabelField("多语言数据概览", EditorStyles.boldLabel); + if (GUILayout.Button("加载 Export/Multilingual 资源信息")) + InspectMultilingualData(); + + // ID 查询 + EditorGUILayout.Space(4); + EditorGUILayout.LabelField("按 ID 查询翻译", EditorStyles.boldLabel); + 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); + EditorGUILayout.LabelField("检视 Mod 文件夹内容", EditorStyles.boldLabel); + EditorGUILayout.BeginHorizontal(); + _inspectFolder = EditorGUILayout.TextField("文件夹路径", _inspectFolder); + if (GUILayout.Button("浏览...", GUILayout.Width(60))) + { + var selected = EditorUtility.OpenFolderPanel("选择 Mod 文件夹", + WorkshopModExporter.GetModRootPath(), ""); + 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.EndScrollView(); + } + } + + private void InspectMultilingualData() + { + var data = Resources.Load("Export/Multilingual"); + if (data == null) { _inspectResult = "❌ 无法加载 Export/Multilingual"; return; } + data.RefreshDict(); + + var sb = new StringBuilder(); + sb.AppendLine("═══ MultilingualData 资源概览 ═══"); + sb.AppendLine($"总条目数: {data.Items.Count}"); + sb.AppendLine($"字典条目数: {data.ItemDict?.Count ?? 0}"); + sb.AppendLine($"字体组数: {data.FontGroups.Count}"); + + int total = data.Items.Count, deprecated = 0; + var langCounts = new Dictionary(); + // 包含 Custom 语种 + for (var t = MultilingualType.ZH; t < MultilingualType.Max; t++) + langCounts[t] = 0; + + foreach (var item in data.Items) + { + if (item.IsDeprecated) { deprecated++; continue; } + for (var t = MultilingualType.ZH; t < MultilingualType.Max; t++) + { + if (!string.IsNullOrEmpty(item.GetStrByType(t))) langCounts[t]++; + } + } + + int active = total - deprecated; + sb.AppendLine($"有效条目: {active} (已弃用: {deprecated})"); + sb.AppendLine("--- 各语言覆盖率 ---"); + foreach (var kv in langCounts) + { + float pct = active > 0 ? (float)kv.Value / active * 100 : 0; + sb.AppendLine($" {kv.Key}: {kv.Value}/{active} ({pct:F1}%)"); + } + + sb.AppendLine("--- 前 5 条数据示例 ---"); + int shown = 0; + foreach (var item in data.Items) + { + if (item.IsDeprecated) continue; + sb.AppendLine($" [{item.ID}] EN=\"{Truncate(item.EN, 30)}\" ZH=\"{Truncate(item.ZH, 30)}\""); + if (++shown >= 5) break; + } + + _inspectResult = sb.ToString(); + } + + private void InspectSearchById() + { + var data = Resources.Load("Export/Multilingual"); + if (data == null) { _inspectSearchResult = "❌ 无法加载资源"; return; } + data.RefreshDict(); + + if (data.ItemDict == null || !data.ItemDict.TryGetValue(_inspectSearchId, out var item)) + { + _inspectSearchResult = $"❌ 未找到 ID={_inspectSearchId}"; + return; + } + + var sb = new StringBuilder(); + 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}"); + sb.AppendLine("--- 各语言文本 ---"); + // 包含 Custom + for (var t = MultilingualType.ZH; t < MultilingualType.Max; t++) + sb.AppendLine($" {t}: \"{Truncate(item.GetStrByType(t), 80)}\""); + + _inspectSearchResult = sb.ToString(); + } + + private void InspectModFolder() + { + if (!Directory.Exists(_inspectFolder)) + { + _inspectResult = $"❌ 文件夹不存在: {_inspectFolder}"; + return; + } + + 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)"); + + var info = WorkshopModExporter.ReadModInfo(_inspectFolder); + if (info != null) + { + sb.AppendLine("\n--- mod_info.json ---"); + 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}"); + } + else sb.AppendLine("\n❌ mod_info.json 不存在或解析失败"); + + var csvPath = Path.Combine(_inspectFolder, WorkshopModExporter.TranslationFileName); + if (File.Exists(csvPath)) + { + sb.AppendLine("\n--- translation.csv (4列格式) ---"); + try + { + var csvContent = File.ReadAllText(csvPath); + 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}] 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}"); } + } + else sb.AppendLine("\n❌ translation.csv 不存在"); + + _inspectResult = sb.ToString(); + } + + private static string Truncate(string s, int maxLen) + { + if (string.IsNullOrEmpty(s)) return "(空)"; + s = s.Replace("\n", "\\n").Replace("\r", ""); + return s.Length <= maxLen ? s : s.Substring(0, maxLen) + "..."; + } +} diff --git a/Unity/Assets/Editor/WorkshopModEditorWindow.cs.meta b/Unity/Assets/Editor/WorkshopModEditorWindow.cs.meta new file mode 100644 index 000000000..72296f647 --- /dev/null +++ b/Unity/Assets/Editor/WorkshopModEditorWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 04a9943eefb92d543a6d0c7bb9f5dc66 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Resources/SpriteAsset/EmojiSpriteAsset_Atlas.png.meta b/Unity/Assets/Resources/SpriteAsset/EmojiSpriteAsset_Atlas.png.meta index 4e7445418..d095e3201 100644 --- a/Unity/Assets/Resources/SpriteAsset/EmojiSpriteAsset_Atlas.png.meta +++ b/Unity/Assets/Resources/SpriteAsset/EmojiSpriteAsset_Atlas.png.meta @@ -3,7 +3,7 @@ guid: f86eae3af4959fa4b8e193bb6a7d7835 TextureImporter: internalIDToNameTable: [] externalObjects: {} - serializedVersion: 13 + serializedVersion: 12 mipmaps: mipMapMode: 0 enableMipMap: 0 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/MultilingualManager.cs b/Unity/Assets/Scripts/TH1_Logic/Multilingual/MultilingualManager.cs index fdc0f8cd6..9498f4ab4 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Multilingual/MultilingualManager.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Multilingual/MultilingualManager.cs @@ -30,8 +30,21 @@ namespace Logic.Multilingual RefreshMultilingualData(); _currentType = ConfigManager.Instance.Config.MultilingualType; if (_currentType == MultilingualType.None) _currentType = GetSystemLanguage(); + ApplyWorkshopMods(); ChangedMultilingual(_currentType); } + + /// + /// 加载并应用所有创意工坊多语言 Mod(本地 + 已订阅),可供 UI 手动调用刷新 + /// + public void ApplyWorkshopMods() + { + RefreshMultilingualData(); + if (_multilingualData != null) + { + WorkshopModLoader.ApplyAllMods(_multilingualData); + } + } #if UNITY_EDITOR public void SetMultilingualType(MultilingualType type) 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 new file mode 100644 index 000000000..2b153b143 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModData.cs @@ -0,0 +1,210 @@ +/* + * @Author: 白哉 + * @Description: 创意工坊多语言 Mod 数据结构与 CSV 读写工具 + * @Date: 2026年04月14日 星期二 14:53:43 + * @Modify: + */ + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Logic.Multilingual +{ + /// + /// Mod 描述文件数据,序列化为 mod_info.json + /// + [Serializable] + public class WorkshopModInfo + { + public string title = ""; + public string author = ""; + public string targetLanguage = ""; + public string description = ""; + public string version = "1.0"; + } + + /// + /// 单条翻译条目 + /// + public struct TranslationEntry + { + public uint Id; + public string EnglishText; // EN 列(翻译标准参考) + public string ReferenceText; // 参考语言列(玩家指定语言,供参考) + public string Translation; // 翻译内容列(玩家填写,空则不替换) + } + + /// + /// CSV 读写工具(RFC 4180 兼容,支持引号内换行和转义引号) + /// CSV 格式为 4 列:ID | EN | {referenceLanguage} | Translation + /// + public static class WorkshopModCsv + { + /// + /// 将翻译条目列表写为 CSV 内容字符串(4 列格式) + /// + /// 参考语言类型(第 3 列标题及内容) + /// 翻译条目列表 + public static string WriteCsv(MultilingualType referenceLanguage, List entries) + { + var sb = new StringBuilder(); + // 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.EnglishText)); + sb.Append(','); + sb.Append(EscapeField(entry.ReferenceText)); + sb.Append(','); + sb.Append(EscapeField(entry.Translation)); // 导出时留空,供玩家填写 + sb.AppendLine(); + } + return sb.ToString(); + } + + /// + /// 解析 CSV 内容字符串,返回有效翻译条目列表(仅返回 Translation 列非空的行) + /// + /// CSV 文本内容 + /// 输出:表头第 3 列的语言标识 + public static List ReadCsv(string content, out string referenceLanguage) + { + referenceLanguage = ""; + var result = new List(); + if (string.IsNullOrEmpty(content)) return result; + + // 移除 UTF-8 BOM + if (content.Length > 0 && content[0] == '\uFEFF') + content = content.Substring(1); + + var rows = ParseCsvRows(content); + if (rows.Count < 2) return result; + + // 解析表头:ID, EN, {referenceLanguage}, Translation + var header = rows[0]; + 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 < 4) continue; + var idStr = row[0].Trim(); + if (!uint.TryParse(idStr, out uint id)) continue; + + // 只有第 4 列(Translation)非空才生效 + var translation = row[3]; + if (string.IsNullOrEmpty(translation)) continue; + + result.Add(new TranslationEntry + { + Id = id, + EnglishText = row[1], + ReferenceText = row[2], + Translation = translation + }); + } + return result; + } + + /// + /// 对 CSV 字段进行转义:包含逗号、引号、换行时用双引号包裹 + /// + private static string EscapeField(string field) + { + if (field == null) return "\"\""; + bool needQuote = field.Contains(",") || field.Contains("\"") || + field.Contains("\n") || field.Contains("\r"); + if (!needQuote) return field; + return "\"" + field.Replace("\"", "\"\"") + "\""; + } + + /// + /// RFC 4180 CSV 状态机解析器,支持引号内换行和转义引号 + /// + private static List ParseCsvRows(string content) + { + var rows = new List(); + var fields = new List(); + var field = new StringBuilder(); + bool inQuotes = false; + int i = 0; + + while (i < content.Length) + { + char c = content[i]; + if (inQuotes) + { + if (c == '"') + { + // 双引号转义 "" → " + if (i + 1 < content.Length && content[i + 1] == '"') + { + field.Append('"'); + i += 2; + } + else + { + inQuotes = false; + i++; + } + } + else + { + field.Append(c); + i++; + } + } + else + { + if (c == '"') + { + inQuotes = true; + i++; + } + else if (c == ',') + { + fields.Add(field.ToString()); + field.Clear(); + i++; + } + else if (c == '\r') + { + fields.Add(field.ToString()); + field.Clear(); + rows.Add(fields.ToArray()); + fields.Clear(); + i++; + if (i < content.Length && content[i] == '\n') i++; + } + else if (c == '\n') + { + fields.Add(field.ToString()); + field.Clear(); + rows.Add(fields.ToArray()); + fields.Clear(); + i++; + } + else + { + field.Append(c); + i++; + } + } + } + + // 处理最后一行(文件末尾无换行的情况) + if (field.Length > 0 || fields.Count > 0) + { + fields.Add(field.ToString()); + rows.Add(fields.ToArray()); + } + + return rows; + } + } +} diff --git a/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModData.cs.meta b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModData.cs.meta new file mode 100644 index 000000000..fb29f445d --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModData.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1de3d8722c5a8834e994edbc6a8cf62c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModExporter.cs b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModExporter.cs new file mode 100644 index 000000000..d3467db5e --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModExporter.cs @@ -0,0 +1,170 @@ +/* + * @Author: 白哉 + * @Description: 创意工坊多语言 Mod 模板导出(一键生成标准 Mod 文件夹) + * @Date: 2026年04月14日 星期二 14:53:43 + * @Modify: + */ + +using System; +using System.Collections.Generic; +using System.IO; +using Logic.CrashSight; +using Steamworks; +using UnityEngine; + +namespace Logic.Multilingual +{ + public static class WorkshopModExporter + { + public const string ModRootFolder = "WorkshopMods"; + public const string ModInfoFileName = "mod_info.json"; + public const string TranslationFileName = "translation.csv"; + public const string PreviewFileName = "preview.png"; + + /// + /// 获取本地 Mod 根目录路径 + /// + public static string GetModRootPath() + { + return Path.Combine(Application.persistentDataPath, ModRootFolder); + } + + /// + /// 导出标准 Mod 翻译模板到本地磁盘 + /// + /// Mod 目标语言(写入 mod_info,表示此 Mod 修改哪个语种字段) + /// 参考语言(CSV 第 3 列,供玩家翻译时参考) + /// Mod 文件夹名称,为空则自动生成 + /// 导出成功返回文件夹路径,失败返回 null + public static string ExportModTemplate(MultilingualType targetLanguage, MultilingualType referenceLanguage, string modName = null) + { + try + { + if (targetLanguage == MultilingualType.None || targetLanguage == MultilingualType.Max) + { + LogSystem.LogError("WorkshopModExporter: 目标语言类型无效,无法导出模板"); + return null; + } + + // 加载多语言数据 + var data = Resources.Load("Export/Multilingual"); + if (data == null) + { + LogSystem.LogError("WorkshopModExporter: 无法加载多语言数据 Export/Multilingual"); + return null; + } + data.RefreshDict(); + + // 生成默认 Mod 文件夹名 + if (string.IsNullOrEmpty(modName)) + 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) + { + if (item.IsDeprecated) continue; + + entries.Add(new TranslationEntry + { + Id = item.ID, + EnglishText = item.EN ?? "", + ReferenceText = item.GetStrByType(referenceLanguage) ?? "", + Translation = "" // 留空,由玩家填写 + }); + } + + // 写入 mod_info.json + var modInfo = new WorkshopModInfo + { + title = $"{targetLanguage} Translation Mod", + author = GetSteamPlayerName(), + targetLanguage = targetLanguage.ToString(), + description = "", + version = "1.0" + }; + var modInfoJson = JsonUtility.ToJson(modInfo, true); + File.WriteAllText( + Path.Combine(modPath, ModInfoFileName), + modInfoJson, + new System.Text.UTF8Encoding(true) + ); + + // 写入 translation.csv(UTF-8 with BOM,Excel 友好) + var csvContent = WorkshopModCsv.WriteCsv(referenceLanguage, entries); + File.WriteAllText( + Path.Combine(modPath, TranslationFileName), + csvContent, + new System.Text.UTF8Encoding(true) + ); + + LogSystem.LogInfo( + $"WorkshopModExporter: Mod 模板已导出到 {modPath},目标语言={targetLanguage},参考语言={referenceLanguage},共 {entries.Count} 条"); + return modPath; + } + catch (Exception e) + { + LogSystem.LogError($"WorkshopModExporter: 导出模板失败 - {e.Message}"); + return null; + } + } + + /// + /// 导出标准 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); + } + + /// + /// 从指定文件夹读取 mod_info.json 并反序列化为 WorkshopModInfo + /// + /// Mod 文件夹路径 + /// 成功返回 WorkshopModInfo,失败返回 null + public static WorkshopModInfo ReadModInfo(string folderPath) + { + if (string.IsNullOrEmpty(folderPath)) return null; + var infoPath = Path.Combine(folderPath, ModInfoFileName); + if (!File.Exists(infoPath)) return null; + try + { + var json = File.ReadAllText(infoPath); + return JsonUtility.FromJson(json); + } + catch (Exception e) + { + LogSystem.LogWarning($"WorkshopModExporter: 读取 mod_info.json 失败 - {e.Message}"); + return null; + } + } + + /// + /// 获取 Steam 玩家昵称,未初始化时返回默认值 + /// + private static string GetSteamPlayerName() + { + try + { + if (SteamAPI.IsSteamRunning()) + return SteamFriends.GetPersonaName(); + } + catch + { + // Steam 未初始化,使用默认名称 + } + return "ModAuthor"; + } + } +} diff --git a/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModExporter.cs.meta b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModExporter.cs.meta new file mode 100644 index 000000000..61c29e058 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModExporter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d9ec2488be1e2de4da73f8500be72ee5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModLoader.cs b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModLoader.cs new file mode 100644 index 000000000..3be01bf26 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModLoader.cs @@ -0,0 +1,268 @@ +/* + * @Author: 白哉 + * @Description: 创意工坊多语言 Mod 加载器(读取本地/订阅 Mod 并覆盖多语言数据) + * @Date: 2026年04月14日 星期二 14:53:43 + * @Modify: + */ + +using System; +using System.Collections.Generic; +using System.IO; +using Logic.CrashSight; +using Steamworks; +using TH1_Logic.Config; +using UnityEngine; + +namespace Logic.Multilingual +{ + public static class WorkshopModLoader + { + /// + /// 加载并应用所有 Mod(先本地后订阅,后加载的覆盖先加载的) + /// 目标语言由各 Mod 的 mod_info.targetLanguage 决定 + /// + /// 成功应用的翻译条目总数 + public static int ApplyAllMods(MultilingualData data) + { + if (data == null) return 0; + data.RefreshDict(); + if (data.ItemDict == null) return 0; + + int count = 0; + // 先应用本地 Mod(优先级较低) + count += ApplyModsFromPaths(data, GetLocalModPaths(), MultilingualType.None); + // 再应用订阅的 Workshop Mod(后加载覆盖前加载) + count += ApplyModsFromPaths(data, GetSubscribedModPaths(), MultilingualType.None); + return count; + } + + /// + /// 按玩家配置的语种-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(), MultilingualType.None); + } + + /// + /// 仅应用本地 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(), MultilingualType.None); + } + + /// + /// 应用指定文件夹中的单个 Mod + /// + /// 多语言数据 + /// Mod 文件夹路径 + /// + /// 强制覆盖目标语言(None = 使用 mod_info.targetLanguage) + /// 玩家可通过优先级配置将任意 Mod 应用到任意语种 + /// + /// 成功覆盖的翻译条目数量 + public static int ApplyModFromFolder(MultilingualData data, string folderPath, + MultilingualType targetLanguageOverride = MultilingualType.None) + { + if (data == null || string.IsNullOrEmpty(folderPath)) return 0; + data.RefreshDict(); + if (data.ItemDict == null) return 0; + + // 1. 读取 mod_info.json + var modInfo = WorkshopModExporter.ReadModInfo(folderPath); + if (modInfo == null) + { + LogSystem.LogWarning($"WorkshopModLoader: 无法读取 mod_info.json - {folderPath}"); + return 0; + } + + // 2. 确定目标语言(优先使用外部覆盖,其次使用 mod_info 声明) + MultilingualType targetType; + if (targetLanguageOverride != MultilingualType.None && targetLanguageOverride != MultilingualType.Max) + { + 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 + var csvPath = Path.Combine(folderPath, WorkshopModExporter.TranslationFileName); + if (!File.Exists(csvPath)) + { + LogSystem.LogWarning($"WorkshopModLoader: translation.csv 不存在 - {folderPath}"); + return 0; + } + + string csvContent; + try + { + csvContent = File.ReadAllText(csvPath); + } + catch (Exception e) + { + LogSystem.LogError($"WorkshopModLoader: 读取 translation.csv 失败 - {e.Message}"); + return 0; + } + + var entries = WorkshopModCsv.ReadCsv(csvContent, out string referenceLanguage); + + // 4. 逐条覆盖多语言数据(仅 Translation 列非空的行生效) + int appliedCount = 0; + foreach (var entry in entries) + { + if (!data.ItemDict.TryGetValue(entry.Id, out var item)) continue; + SetItemStr(item, targetType, entry.Translation); + appliedCount++; + } + + if (appliedCount > 0) + { + LogSystem.LogInfo( + $"WorkshopModLoader: 已应用 Mod \"{modInfo.title}\" (目标语言={targetType},参考语言={referenceLanguage}),覆盖 {appliedCount}/{entries.Count} 条翻译"); + } + + return appliedCount; + } + + /// + /// 获取已订阅且已安装的 Workshop 多语言 Mod 本地路径列表 + /// + public static List GetSubscribedModPaths() + { + var paths = new List(); + try + { + uint numSubscribed = SteamUGC.GetNumSubscribedItems(); + if (numSubscribed == 0) return paths; + + var subscribedIds = new PublishedFileId_t[numSubscribed]; + SteamUGC.GetSubscribedItems(subscribedIds, numSubscribed); + + foreach (var fileId in subscribedIds) + { + var state = (EItemState)SteamUGC.GetItemState(fileId); + if ((state & EItemState.k_EItemStateInstalled) == 0) continue; + + if (SteamUGC.GetItemInstallInfo(fileId, out ulong _, out string folder, 1024, + out uint _)) + { + if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder)) continue; + // 仅收集包含 mod_info.json 的文件夹(确认为多语言 Mod) + if (File.Exists(Path.Combine(folder, WorkshopModExporter.ModInfoFileName))) + paths.Add(folder); + } + } + } + catch (Exception e) + { + LogSystem.LogWarning($"WorkshopModLoader: 获取 Workshop 订阅列表失败 - {e.Message}"); + } + + return paths; + } + + /// + /// 获取本地 WorkshopMods 目录下所有 Mod 文件夹路径 + /// + public static List GetLocalModPaths() + { + var paths = new List(); + var rootPath = WorkshopModExporter.GetModRootPath(); + if (!Directory.Exists(rootPath)) return paths; + + foreach (var dir in Directory.GetDirectories(rootPath)) + { + if (File.Exists(Path.Combine(dir, WorkshopModExporter.ModInfoFileName))) + paths.Add(dir); + } + + return 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, targetOverride); + } + return totalCount; + } + + private static MultilingualType ParseLanguageType(string languageStr) + { + if (string.IsNullOrEmpty(languageStr)) return MultilingualType.None; + if (Enum.TryParse(languageStr, true, out var type)) + return type; + return MultilingualType.None; + } + + /// + /// 按语言类型设置 MultilingualItem 对应字段的文本值 + /// + private static void SetItemStr(MultilingualItem item, MultilingualType type, string value) + { + 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.Custom: item.Custom = value; break; + } + } + } +} diff --git a/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModLoader.cs.meta b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModLoader.cs.meta new file mode 100644 index 000000000..d7806f281 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModLoader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2a814a53782946b4c9213d1f11394cca +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModUploader.cs b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModUploader.cs new file mode 100644 index 000000000..d0d914964 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModUploader.cs @@ -0,0 +1,348 @@ +/* + * @Author: 白哉 + * @Description: 创意工坊多语言 Mod 上传管理(通过 Steamworks ISteamUGC 接口) + * @Date: 2026年04月14日 星期二 14:53:43 + * @Modify: 2026年04月16日 - 改用 SteamUtils.IsAPICallCompleted 轮询替代 CallResult 回调 + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using Logic.CrashSight; +using Steamworks; + +namespace Logic.Multilingual +{ + public class WorkshopModUploader + { + public static WorkshopModUploader Instance = new WorkshopModUploader(); + + /// + /// 上传阶段枚举 + /// + private enum Phase { None, Creating, Submitting } + private Phase _phase = Phase.None; + + // 当前挂起的 Steamworks API 调用句柄 + private SteamAPICall_t _pendingApiCall; + private PublishedFileId_t _createdFileId; + + // 待上传的参数 + private string _pendingFolder; + private string _pendingTitle; + private string _pendingDescription; + private string _pendingPreview; + private string _pendingChangeNote; + + // 回调 + private Action _onCreateAndUploadComplete; + private Action _onUpdateComplete; + + /// + /// 当前是否有上传操作进行中 + /// + public bool IsBusy => _phase != Phase.None; + + /// + /// 当前上传状态的可读描述(供 UI 展示) + /// + public string StatusMessage { get; private set; } = ""; + + /// + /// 创建新的 Workshop 物品并上传 Mod 内容 + /// + public void CreateAndUploadMod(string folderPath, string title, string description, + string previewImagePath, Action onComplete) + { + if (IsBusy) + { + LogSystem.LogError("WorkshopModUploader: 上一次操作尚未完成"); + onComplete?.Invoke(false, default); + return; + } + + if (!Directory.Exists(folderPath)) + { + LogSystem.LogError($"WorkshopModUploader: Mod 文件夹不存在 - {folderPath}"); + onComplete?.Invoke(false, default); + return; + } + + _pendingFolder = folderPath; + _pendingTitle = title; + _pendingDescription = description; + _pendingPreview = previewImagePath; + _pendingChangeNote = "Initial upload"; + _onCreateAndUploadComplete = onComplete; + _onUpdateComplete = null; + + try + { + var appId = SteamUtils.GetAppID(); + _pendingApiCall = SteamUGC.CreateItem(appId, EWorkshopFileType.k_EWorkshopFileTypeCommunity); + _phase = Phase.Creating; + StatusMessage = "正在创建 Workshop 物品..."; + LogSystem.LogInfo($"WorkshopModUploader: {StatusMessage} (APICall={_pendingApiCall})"); + } + catch (Exception e) + { + LogSystem.LogError($"WorkshopModUploader: 创建 Workshop 物品异常 - {e.Message}"); + FinishWithError($"创建异常: {e.Message}"); + } + } + + /// + /// 更新已有的 Workshop 物品内容 + /// + public void UpdateMod(PublishedFileId_t publishedFileId, string folderPath, + string changeNote, Action onComplete) + { + if (IsBusy) + { + LogSystem.LogError("WorkshopModUploader: 上一次操作尚未完成"); + onComplete?.Invoke(false); + return; + } + + if (!Directory.Exists(folderPath)) + { + LogSystem.LogError($"WorkshopModUploader: Mod 文件夹不存在 - {folderPath}"); + onComplete?.Invoke(false); + return; + } + + _pendingFolder = folderPath; + _pendingChangeNote = changeNote; + _onUpdateComplete = onComplete; + _onCreateAndUploadComplete = null; + _createdFileId = publishedFileId; + + StartSubmitContent(publishedFileId, null, null, folderPath, null, changeNote); + } + + /// + /// 每帧轮询挂起的 Steamworks API 调用是否已完成。 + /// 必须由外部每帧调用(编辑器: EditorApplication.update,运行时: MonoBehaviour.Update) + /// + public void Poll() + { + if (_phase == Phase.None) return; + + bool completed = SteamUtils.IsAPICallCompleted(_pendingApiCall, out bool ioFailed); + if (!completed) return; + + if (ioFailed) + { + var reason = SteamUtils.GetAPICallFailureReason(_pendingApiCall); + FinishWithError($"API 调用失败 (Phase={_phase}, Reason={reason})"); + return; + } + + switch (_phase) + { + case Phase.Creating: + OnCreateCompleted(); + break; + case Phase.Submitting: + OnSubmitCompleted(); + break; + } + } + + /// + /// 获取 Workshop 物品的上传进度(仅在 Submitting 阶段有效) + /// + public static EItemUpdateStatus GetUploadProgress(UGCUpdateHandle_t handle, + out ulong bytesDone, out ulong bytesTotal) + { + return SteamUGC.GetItemUpdateProgress(handle, out bytesDone, out bytesTotal); + } + + /// + /// 取消当前操作(重置状态,不会撤销已创建的 Steam 物品) + /// + public void Cancel() + { + if (_phase == Phase.None) return; + LogSystem.LogWarning("WorkshopModUploader: 操作已取消"); + _phase = Phase.None; + StatusMessage = "已取消"; + _onCreateAndUploadComplete = null; + _onUpdateComplete = null; + } + + // ──────────────────────────────────────────────────────── + // 内部:阶段处理 + // ──────────────────────────────────────────────────────── + + private void OnCreateCompleted() + { + try + { + var result = MarshalAPICallResult( + _pendingApiCall, CreateItemResult_t.k_iCallback); + + if (result.m_eResult != EResult.k_EResultOK) + { + FinishWithError( + $"创建物品失败: Result={result.m_eResult}, " + + $"需要同意协议={result.m_bUserNeedsToAcceptWorkshopLegalAgreement}"); + return; + } + + _createdFileId = result.m_nPublishedFileId; + StatusMessage = $"物品已创建 (FileId={_createdFileId}),正在上传内容..."; + LogSystem.LogInfo($"WorkshopModUploader: {StatusMessage}"); + + // 立即开始上传内容 + StartSubmitContent(_createdFileId, _pendingTitle, _pendingDescription, + _pendingFolder, _pendingPreview, _pendingChangeNote); + } + catch (Exception e) + { + FinishWithError($"处理创建结果异常: {e.Message}\n{e.StackTrace}"); + } + } + + private void StartSubmitContent(PublishedFileId_t fileId, string title, string description, + string folder, string preview, string changeNote) + { + try + { + var appId = SteamUtils.GetAppID(); + var handle = SteamUGC.StartItemUpdate(appId, fileId); + + if (!string.IsNullOrEmpty(title)) + SteamUGC.SetItemTitle(handle, title); + + if (!string.IsNullOrEmpty(description)) + SteamUGC.SetItemDescription(handle, description); + + SteamUGC.SetItemContent(handle, folder); + + if (!string.IsNullOrEmpty(preview) && File.Exists(preview)) + SteamUGC.SetItemPreview(handle, preview); + + SetModTags(handle, folder); + + SteamUGC.SetItemVisibility(handle, + ERemoteStoragePublishedFileVisibility.k_ERemoteStoragePublishedFileVisibilityPublic); + + _pendingApiCall = SteamUGC.SubmitItemUpdate(handle, changeNote ?? ""); + _phase = Phase.Submitting; + StatusMessage = "正在上传 Mod 内容到 Steam..."; + LogSystem.LogInfo($"WorkshopModUploader: {StatusMessage} (APICall={_pendingApiCall})"); + } + catch (Exception e) + { + FinishWithError($"上传内容异常: {e.Message}\n{e.StackTrace}"); + } + } + + private void OnSubmitCompleted() + { + try + { + var result = MarshalAPICallResult( + _pendingApiCall, SubmitItemUpdateResult_t.k_iCallback); + + if (result.m_eResult != EResult.k_EResultOK) + { + FinishWithError( + $"提交更新失败: Result={result.m_eResult}, " + + $"需要同意协议={result.m_bUserNeedsToAcceptWorkshopLegalAgreement}"); + return; + } + + StatusMessage = "Mod 上传成功!"; + LogSystem.LogInfo($"WorkshopModUploader: {StatusMessage}"); + + // 成功完成 + _phase = Phase.None; + + if (_onCreateAndUploadComplete != null) + { + var cb = _onCreateAndUploadComplete; + _onCreateAndUploadComplete = null; + cb.Invoke(true, _createdFileId); + } + else if (_onUpdateComplete != null) + { + var cb = _onUpdateComplete; + _onUpdateComplete = null; + cb.Invoke(true); + } + } + catch (Exception e) + { + FinishWithError($"处理提交结果异常: {e.Message}\n{e.StackTrace}"); + } + } + + // ──────────────────────────────────────────────────────── + // 工具方法 + // ──────────────────────────────────────────────────────── + + private void FinishWithError(string message) + { + StatusMessage = message; + LogSystem.LogError($"WorkshopModUploader: {message}"); + _phase = Phase.None; + + if (_onCreateAndUploadComplete != null) + { + var cb = _onCreateAndUploadComplete; + _onCreateAndUploadComplete = null; + cb.Invoke(false, default); + } + else if (_onUpdateComplete != null) + { + var cb = _onUpdateComplete; + _onUpdateComplete = null; + cb.Invoke(false); + } + } + + /// + /// 手动获取已完成的 API 调用结果(绕过 CallResult 回调机制,直接用 Marshal 读取) + /// + private static T MarshalAPICallResult(SteamAPICall_t apiCall, int expectedCallbackId) + where T : struct + { + int size = Marshal.SizeOf(typeof(T)); + IntPtr ptr = Marshal.AllocHGlobal(size); + try + { + bool failed; + bool ok = SteamUtils.GetAPICallResult(apiCall, ptr, size, expectedCallbackId, out failed); + if (!ok || failed) + { + throw new Exception( + $"GetAPICallResult 失败: ok={ok}, failed={failed}, callbackId={expectedCallbackId}"); + } + return (T)Marshal.PtrToStructure(ptr, typeof(T)); + } + finally + { + Marshal.FreeHGlobal(ptr); + } + } + + private void SetModTags(UGCUpdateHandle_t handle, string folder) + { + var tags = new List { "Multilingual" }; + try + { + var modInfo = WorkshopModExporter.ReadModInfo(folder); + if (modInfo != null && !string.IsNullOrEmpty(modInfo.targetLanguage)) + tags.Add(modInfo.targetLanguage); + } + catch + { + // 读取失败仅使用默认标签 + } + SteamUGC.SetItemTags(handle, tags); + } + } +} diff --git a/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModUploader.cs.meta b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModUploader.cs.meta new file mode 100644 index 000000000..354bd26c9 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/Multilingual/WorkshopModUploader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a4119ec4f187dc54b9bd08d6a9a16bba +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: