多语言mod推进

This commit is contained in:
wuwenbo 2026-04-16 14:36:17 +08:00
parent da34fe20a8
commit 263204736f
9 changed files with 1092 additions and 354 deletions

View File

@ -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 的多语言数据节点。
**关于嵌套引用的兼容性:**
原系统的 `**<id>**` 专有名词嵌套引用机制与 Mod 系统完全兼容。只要玩家在 Translation 列保留了 `**<id>**` 格式,运行时底层仍会正常触发解析,无需任何特殊处理。
**关于嵌套引用的特殊说明:**
原系统中的 `**<id>**` 专有名词嵌套引用机制在 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 列格式) |

File diff suppressed because it is too large Load Diff

View File

@ -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;
/// <summary>
/// 按优先级排序的 Mod 文件夹路径列表
/// 索引 0 = 最低优先级(最先应用),最后一项 = 最高优先级(最后应用,可覆盖前面的)
/// </summary>
public List<string> ModPaths = new List<string>();
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<ModLanguageConfig> _modLanguageConfigs = new List<ModLanguageConfig>();
private bool _isChanged;
public bool IsChanged => _isChanged;
@ -274,6 +292,76 @@ namespace TH1_Logic.Config
_showReminder = true;
_keyMomentEnabled = true;
_bgmContinuousPlay = false;
_modLanguageConfigs = new List<ModLanguageConfig>();
}
// ── Mod 优先级配置接口 ──
/// <summary>获取所有语种的 Mod 配置列表(只读)</summary>
public List<ModLanguageConfig> ModLanguageConfigs => _modLanguageConfigs;
/// <summary>获取指定语种的 Mod 配置,不存在则创建</summary>
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;
}
/// <summary>设置指定语种的 Mod 优先级列表(覆盖现有)</summary>
public void SetModsForLanguage(MultilingualType language, List<string> orderedPaths)
{
var config = GetOrCreateModConfig(language);
config.ModPaths = new List<string>(orderedPaths);
_isChanged = true;
}
/// <summary>向指定语种添加一个 Mod加到列表末尾最高优先级</summary>
public void AddModToLanguage(MultilingualType language, string modPath)
{
var config = GetOrCreateModConfig(language);
if (!config.ModPaths.Contains(modPath))
{
config.ModPaths.Add(modPath);
_isChanged = true;
}
}
/// <summary>从指定语种移除一个 Mod</summary>
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;
}
/// <summary>移动指定语种 Mod 的优先级(向上提升=降低索引,向下降低=增加索引)</summary>
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;
}
/// <summary>清除指定语种的所有 Mod 配置</summary>
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;
}
}
}
}

View File

@ -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,
};
}

View File

@ -0,0 +1,251 @@
/*
* @Author:
* @Description: Mod AppId Workshop /
* @Date: 20260416
* @Modify:
*/
using System;
using System.Collections.Generic;
using Logic.CrashSight;
using Steamworks;
namespace Logic.Multilingual
{
/// <summary>
/// Workshop 物品的简要信息(用于列表展示)
/// </summary>
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;
}
/// <summary>
/// 创意工坊 Mod 浏览器,基于 Steamworks ISteamUGC 接口
/// 支持分页查询、订阅、取消订阅操作
/// </summary>
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<WorkshopModItem> Results { get; } = new List<WorkshopModItem>();
public string LastError { get; private set; } = "";
// ── 订阅/取消订阅状态 ──
public bool IsSubscribeOperating { get; private set; }
private CallResult<SteamUGCQueryCompleted_t> _queryCallResult;
private CallResult<RemoteStorageSubscribePublishedFileResult_t> _subscribeCallResult;
private CallResult<RemoteStorageUnsubscribePublishedFileResult_t> _unsubscribeCallResult;
private UGCQueryHandle_t _pendingQueryHandle;
public event System.Action OnQueryCompleted;
public event Action<PublishedFileId_t, bool> OnSubscribeCompleted;
public event Action<PublishedFileId_t, bool> OnUnsubscribeCompleted;
/// <summary>
/// 查询当前 AppId 的所有 Workshop Mod分页
/// </summary>
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<SteamUGCQueryCompleted_t>.Create(OnSteamQueryCompleted);
_queryCallResult.Set(apiCall);
}
catch (Exception e)
{
IsQuerying = false;
LastError = $"查询异常: {e.Message}";
LogSystem.LogError($"WorkshopModBrowser: 查询失败 - {e.Message}");
}
}
/// <summary>
/// 订阅指定 Workshop 物品
/// </summary>
public void Subscribe(PublishedFileId_t fileId)
{
if (IsSubscribeOperating) return;
try
{
IsSubscribeOperating = true;
var apiCall = SteamUGC.SubscribeItem(fileId);
_subscribeCallResult = CallResult<RemoteStorageSubscribePublishedFileResult_t>.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);
}
}
/// <summary>
/// 取消订阅指定 Workshop 物品
/// </summary>
public void Unsubscribe(PublishedFileId_t fileId)
{
if (IsSubscribeOperating) return;
try
{
IsSubscribeOperating = true;
var apiCall = SteamUGC.UnsubscribeItem(fileId);
_unsubscribeCallResult = CallResult<RemoteStorageUnsubscribePublishedFileResult_t>.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);
}
}
/// <summary>
/// 刷新 Steam 回调(需外部在合适的时机定期调用,如 EditorApplication.update 或游戏的 Update
/// </summary>
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 = "";
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 46d2fb86290a5344c9569512a293e1f1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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; // 翻译内容列(玩家填写,空则不替换)
}
/// <summary>
/// CSV 读写工具RFC 4180 兼容,支持引号内换行和转义引号)
/// CSV 格式为 4 列ID | EN | {referenceLanguage} | Translation
/// </summary>
public static class WorkshopModCsv
{
/// <summary>
/// 将翻译条目列表写为 CSV 内容字符串
/// 将翻译条目列表写为 CSV 内容字符串4 列格式)
/// </summary>
public static string WriteCsv(MultilingualType targetType, List<TranslationEntry> entries)
/// <param name="referenceLanguage">参考语言类型(第 3 列标题及内容)</param>
/// <param name="entries">翻译条目列表</param>
public static string WriteCsv(MultilingualType referenceLanguage, List<TranslationEntry> 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();
}
/// <summary>
/// 解析 CSV 内容字符串,返回翻译条目列表
/// 解析 CSV 内容字符串,返回有效翻译条目列表(仅返回 Translation 列非空的行)
/// </summary>
/// <param name="content">CSV 文本内容</param>
/// <param name="headerLanguage">输出:表头第三列的语言标识</param>
public static List<TranslationEntry> ReadCsv(string content, out string headerLanguage)
/// <param name="referenceLanguage">输出:表头第 3 列的语言标识</param>
public static List<TranslationEntry> ReadCsv(string content, out string referenceLanguage)
{
headerLanguage = "";
referenceLanguage = "";
var result = new List<TranslationEntry>();
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
});
}

View File

@ -31,18 +31,18 @@ namespace Logic.Multilingual
/// <summary>
/// 导出标准 Mod 翻译模板到本地磁盘
/// 按当前玩家选择的语言导出ID + 中文原文 + 玩家语言文本
/// </summary>
/// <param name="targetLanguage">Mod 目标语言(写入 mod_info表示此 Mod 修改哪个语种字段)</param>
/// <param name="referenceLanguage">参考语言CSV 第 3 列,供玩家翻译时参考)</param>
/// <param name="modName">Mod 文件夹名称,为空则自动生成</param>
/// <returns>导出成功返回文件夹路径,失败返回 null</returns>
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<TranslationEntry>();
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.csvUTF-8 with BOMExcel 友好)
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
}
}
/// <summary>
/// 导出标准 Mod 翻译模板(使用当前游戏语言作为参考语言的便捷重载)
/// </summary>
/// <param name="modName">Mod 文件夹名称,为空则自动生成</param>
/// <returns>导出成功返回文件夹路径,失败返回 null</returns>
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);
}
/// <summary>
/// 获取 Steam 玩家昵称,未初始化时返回默认值
/// </summary>

View File

@ -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
{
/// <summary>
/// 加载并应用所有 Mod先本地后订阅后加载的覆盖先加载的
/// 目标语言由各 Mod 的 mod_info.targetLanguage 决定
/// </summary>
/// <returns>成功应用的翻译条目总数</returns>
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;
}
/// <summary>
/// 仅应用已订阅的 Workshop Mod
/// 按玩家配置的语种-Mod 优先级设置应用所有 Mod
/// 每个语种对应一个有序 Mod 列表,低索引=低优先级(先应用),高索引=高优先级(后应用可覆盖)
/// </summary>
/// <param name="data">多语言数据</param>
/// <param name="configs">语种-Mod 配置列表</param>
/// <returns>成功应用的翻译条目总数</returns>
public static int ApplyModsWithConfig(MultilingualData data, List<ModLanguageConfig> 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;
}
/// <summary>
/// 仅应用已订阅的 Workshop Mod目标语言由 mod_info 决定)
/// </summary>
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);
}
/// <summary>
/// 仅应用本地 Mod
/// 仅应用本地 Mod(目标语言由 mod_info 决定)
/// </summary>
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);
}
/// <summary>
/// 应用指定文件夹中的单个 Mod
/// </summary>
/// <param name="data">多语言数据</param>
/// <param name="folderPath">Mod 文件夹路径</param>
/// <param name="targetLanguageOverride">
/// 强制覆盖目标语言None = 使用 mod_info.targetLanguage
/// 玩家可通过优先级配置将任意 Mod 应用到任意语种
/// </param>
/// <returns>成功覆盖的翻译条目数量</returns>
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<string> paths)
/// <summary>
/// 获取所有可用的 Mod 路径(本地 + 订阅)
/// </summary>
public static List<string> GetAllAvailableModPaths()
{
var paths = new List<string>(GetLocalModPaths());
paths.AddRange(GetSubscribedModPaths());
return paths;
}
private static int ApplyModsFromPaths(MultilingualData data, List<string> 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;
}
}
}