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
|
{
|
/// <summary>
|
/// 抖音小游戏(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。
|
/// </summary>
|
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 修复完成。");
|
}
|
|
/// <summary>
|
/// 主 game.js:loadDataPackageFromSubpackage 必须为 false(CDN 模式)。
|
/// </summary>
|
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。");
|
}
|
|
/// <summary>
|
/// 子包 game.js:0 字节 → module.exports = {};(已有内容则跳过,幂等)。
|
/// </summary>
|
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 = {};");
|
}
|
|
/// <summary>
|
/// game.json:补 networkTimeout,提高 tt.request 超时(抖音默认 60s,慢 CDN 下会超时)。
|
/// </summary>
|
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)。");
|
}
|
|
/// <summary>
|
/// 加载页图片:用工程内 Assets/Editor/DouyinLoading 下的自定义图覆盖产物 images 目录,
|
/// 不修改 DefaultTemplate。background.png 为背景图、logo.png 为 LOGO 图标
|
/// (产物里叫 unity_logo.png,保持文件名不变,game.js 无需改动)。源图不存在则跳过。
|
/// </summary>
|
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"));
|
}
|
|
/// <summary>
|
/// 星玩渠道 SDK:确保小游戏根目录存在 xingwanTTGameSdk.js。
|
/// 构建工具可能未把 DefaultTemplate 下的自定义 js 拷进产物,这里兜底复制。
|
/// </summary>
|
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);
|
}
|
|
/// <summary>
|
/// 星玩渠道 SDK:确保产物 game.js 已 import 并把 SDK 挂到全局作用域。
|
/// 模板被还原/覆盖时自动补回,避免 jslib 找不到 SDK。
|
/// </summary>
|
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");
|
}
|
|
/// <summary>
|
/// 从 Assets/Editor/StarkBuilderSetting.asset 读取 OutputDir,
|
/// 避免写死路径;读取失败时回退到 工程根/tt-minigame。
|
/// </summary>
|
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;
|
}
|
}
|
}
|