using System; using System.IO; using System.Text; using System.Text.RegularExpressions; using UnityEditor; using UnityEditor.Build; using UnityEditor.Build.Reporting; using UnityEngine; namespace ProjSG.Editor { /// /// 抖音小游戏(TTSDK 6.8.0)构建后自动修复。 /// /// 背景: /// 1. 本项目固定 CDN 模式(dataLoadType: 0),首包数据部署在 DATA_CDN 上。 /// 但 TTSDK 构建工具在生成 tt-minigame/tt-minigame/game.js 时, /// 会把 loadDataPackageFromSubpackage 写成 true(无论模板/dataLoadType 如何), /// 导致引擎去空的 data-package 子包找首包数据,报 "processDataJob: unknown data format"。 /// 2. TTSDK 生成的子包 wasmcode/game.js 与 data-package/game.js 是 0 字节空文件, /// 真机会报 "not loaded pkg:game.js",必须补成 module.exports = {};。 /// /// 用法: /// - 构建 WebGL(抖音小游戏)完成后自动执行(IPostprocessBuildWithReport + 延迟重试)。 /// - 也可手动执行菜单 Tools/抖音小游戏/修复 game.js。 /// public class DouyinBuildPostFixer : IPostprocessBuildWithReport { public int callbackOrder => 9999; // 尽量晚执行 private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(false); private const int MaxRetry = 20; // 最多重试 20 次(每次间隔 1 帧),应对转换异步的情况 // --------------------------------------------------------------- // 自动 Hook // --------------------------------------------------------------- public void OnPostprocessBuild(BuildReport report) { bool isMiniGame = report.summary.platform == BuildTarget.WebGL; if (!isMiniGame) { var outputPath = report.summary.outputPath ?? string.Empty; isMiniGame = outputPath.IndexOf("tt-minigame", StringComparison.OrdinalIgnoreCase) >= 0; } if (!isMiniGame) return; // TTSDK 转小游戏(生成 game.js)发生在 Unity WebGL 构建之后, // 且是同步的,所以把修复延后到下一帧,确保产物已落盘。 EditorApplication.delayCall += () => PatchWithRetry(0); } [MenuItem("Tools/抖音小游戏/修复 game.js(构建后自动/手动)")] public static void PatchMenu() { PatchWithRetry(0); } // --------------------------------------------------------------- // 修复入口 // --------------------------------------------------------------- private static void PatchWithRetry(int attempt) { var miniRoot = GetMiniGameRoot(); // 主 game.js 还没生成(构建尚未完成/转换异步),稍后重试 if (!File.Exists(Path.Combine(miniRoot, "game.js"))) { if (attempt < MaxRetry) { var next = attempt + 1; EditorApplication.delayCall += () => PatchWithRetry(next); } else { Debug.LogWarning("[DouyinBuildPostFixer] 未找到构建产物 " + miniRoot + ",请确认已用 TTSDK 打包,或手动执行菜单 Tools/抖音小游戏/修复 game.js。"); } return; } try { PatchAll(miniRoot); } catch (Exception e) { Debug.LogError("[DouyinBuildPostFixer] 修复失败: " + e); } } // --------------------------------------------------------------- // 实际修复逻辑 // --------------------------------------------------------------- public static void PatchAll(string miniRoot) { PatchMainGameJs(Path.Combine(miniRoot, "game.js")); PatchSubpackageGameJs(Path.Combine(miniRoot, "wasmcode", "game.js"), "wasmcode"); PatchSubpackageGameJs(Path.Combine(miniRoot, "data-package", "game.js"), "data-package"); // wasm 分包(开发者工具「启用代码分包」)会生成 .dy-game-cache 缓存,里面的 wasmcode 子包 // 可能缺 game.js,导致真机预览报 "not loaded pkg:game.js",同样补上。 PatchSubpackageGameJs(Path.Combine(miniRoot, ".dy-game-cache", "wasmcode", "game.js"), ".dy-game-cache/wasmcode"); PatchGameJson(Path.Combine(miniRoot, "game.json")); PatchLoadingImages(miniRoot); EnsureXingwanSdkFile(miniRoot); EnsureXingwanImport(Path.Combine(miniRoot, "game.js")); Debug.Log("[DouyinBuildPostFixer] 构建产物 game.js/game.json 修复完成。"); } /// /// 主 game.js:loadDataPackageFromSubpackage 必须为 false(CDN 模式)。 /// private static void PatchMainGameJs(string path) { if (!File.Exists(path)) { Debug.LogWarning("[DouyinBuildPostFixer] 主 game.js 不存在: " + path); return; } string text = File.ReadAllText(path); // 自动识别 BOM string newText = Regex.Replace( text, @"loadDataPackageFromSubpackage\s*:\s*true", "loadDataPackageFromSubpackage: false"); if (newText == text) { Debug.Log("[DouyinBuildPostFixer] 主 game.js 的 loadDataPackageFromSubpackage 已是 false,无需修改。"); return; } File.WriteAllText(path, newText, Utf8NoBom); Debug.Log("[DouyinBuildPostFixer] 已修复主 game.js:loadDataPackageFromSubpackage true → false。"); } /// /// 子包 game.js:0 字节 → module.exports = {};(已有内容则跳过,幂等)。 /// private static void PatchSubpackageGameJs(string path, string subName) { Directory.CreateDirectory(Path.GetDirectoryName(path) ?? string.Empty); if (File.Exists(path) && new FileInfo(path).Length > 0) { Debug.Log("[DouyinBuildPostFixer] 子包 " + subName + "/game.js 已有内容,跳过。"); return; } File.WriteAllText(path, "module.exports = {};\n", Utf8NoBom); Debug.Log("[DouyinBuildPostFixer] 已补全子包 " + subName + "/game.js:module.exports = {};"); } /// /// game.json:补 networkTimeout,提高 tt.request 超时(抖音默认 60s,慢 CDN 下会超时)。 /// private static void PatchGameJson(string path) { if (!File.Exists(path)) { Debug.LogWarning("[DouyinBuildPostFixer] game.json 不存在: " + path); return; } string text = File.ReadAllText(path); if (text.Contains("\"networkTimeout\"")) { Debug.Log("[DouyinBuildPostFixer] game.json 已有 networkTimeout,跳过。"); return; } const string block = " \"networkTimeout\": {\n" + " \"request\": 300000,\n" + " \"connectSocket\": 300000,\n" + " \"uploadFile\": 300000,\n" + " \"downloadFile\": 300000\n" + " },\n"; // 在 "subpackages" 前插入,若找不到则插到 "plugins" 前,再不行插到第一个 { 后。 int idx = text.IndexOf("\"subpackages\"", StringComparison.Ordinal); if (idx < 0) idx = text.IndexOf("\"plugins\"", StringComparison.Ordinal); if (idx < 0) { idx = text.IndexOf('{'); if (idx >= 0) idx += 1; } if (idx < 0) { Debug.LogWarning("[DouyinBuildPostFixer] game.json 结构异常,跳过 networkTimeout 补丁。"); return; } text = text.Insert(idx, block); File.WriteAllText(path, text, Utf8NoBom); Debug.Log("[DouyinBuildPostFixer] 已为 game.json 补 networkTimeout(request/downloadFile = 300000ms)。"); } /// /// 加载页图片:用工程内 Assets/Editor/DouyinLoading 下的自定义图覆盖产物 images 目录, /// 不修改 DefaultTemplate。background.png 为背景图、logo.png 为 LOGO 图标 /// (产物里叫 unity_logo.png,保持文件名不变,game.js 无需改动)。源图不存在则跳过。 /// private static void PatchLoadingImages(string miniRoot) { var sourceDir = Path.Combine(Application.dataPath, "Editor", "DouyinLoading"); var targetDir = Path.Combine(miniRoot, "images"); CopyIfExists(Path.Combine(sourceDir, "background.png"), Path.Combine(targetDir, "background.png")); CopyIfExists(Path.Combine(sourceDir, "logo.png"), Path.Combine(targetDir, "unity_logo.png")); } /// /// 星玩渠道 SDK:确保小游戏根目录存在 xingwanTTGameSdk.js。 /// 构建工具可能未把 DefaultTemplate 下的自定义 js 拷进产物,这里兜底复制。 /// private static void EnsureXingwanSdkFile(string miniRoot) { const string fileName = "xingwanTTGameSdk.js"; var target = Path.Combine(miniRoot, fileName); if (File.Exists(target) && new FileInfo(target).Length > 0) return; var source = Path.Combine(Application.dataPath, "Plugins", "ByteGame", "com.bytedance.starksdk", "DefaultTemplate", fileName); if (!File.Exists(source)) { Debug.LogWarning("[DouyinBuildPostFixer] 未找到星玩 SDK 源文件: " + source); return; } Directory.CreateDirectory(miniRoot); File.Copy(source, target, true); Debug.Log("[DouyinBuildPostFixer] 已复制星玩 SDK 到产物目录: " + fileName); } /// /// 星玩渠道 SDK:确保产物 game.js 已 import 并把 SDK 挂到全局作用域。 /// 模板被还原/覆盖时自动补回,避免 jslib 找不到 SDK。 /// private static void EnsureXingwanImport(string gameJsPath) { if (!File.Exists(gameJsPath)) return; string text = File.ReadAllText(gameJsPath); if (text.Contains("xingwanTTGameSdk")) return; const string block = "// 星玩渠道 SDK 注入(构建后自动补回)\n" + "try {\n" + " const xingwanModule = require('./xingwanTTGameSdk.js');\n" + " const xingwanTTGameSdk = (xingwanModule && xingwanModule.default) || xingwanModule;\n" + " GameGlobal.xingwanTTGameSdk = xingwanTTGameSdk;\n" + " if (typeof globalThis !== 'undefined') globalThis.xingwanTTGameSdk = xingwanTTGameSdk;\n" + " if (typeof window !== 'undefined') window.xingwanTTGameSdk = xingwanTTGameSdk;\n" + "} catch (e) {\n" + " console.error('[Xingwan] 星玩 SDK 注入失败: ' + (e && e.message ? e.message : e));\n" + "}\n"; File.WriteAllText(gameJsPath, block + text, Utf8NoBom); Debug.Log("[DouyinBuildPostFixer] 已为 game.js 补回星玩 SDK 注入代码。"); } private static void CopyIfExists(string source, string target) { if (!File.Exists(source)) return; Directory.CreateDirectory(Path.GetDirectoryName(target) ?? string.Empty); File.Copy(source, target, true); Debug.Log("[DouyinBuildPostFixer] 已覆盖加载页图片: " + Path.GetFileName(target) + " <- " + source); } // --------------------------------------------------------------- // 路径解析 // --------------------------------------------------------------- private static string GetMiniGameRoot() { string outputRoot = ReadStarkOutputDir(); if (string.IsNullOrEmpty(outputRoot)) { outputRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..", "tt-minigame")); } return Path.Combine(outputRoot, "tt-minigame"); } /// /// 从 Assets/Editor/StarkBuilderSetting.asset 读取 OutputDir, /// 避免写死路径;读取失败时回退到 工程根/tt-minigame。 /// private static string ReadStarkOutputDir() { string settingPath = Path.Combine(Application.dataPath, "Editor", "StarkBuilderSetting.asset"); if (!File.Exists(settingPath)) return null; string txt = File.ReadAllText(settingPath); var m = Regex.Match(txt, @"^OutputDir:\s*(.+)$", RegexOptions.Multiline); if (!m.Success) return null; string dir = m.Groups[1].Value.Trim(); return string.IsNullOrEmpty(dir) ? null : dir; } } }