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