Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,10 @@ yarn-error.log*

# ide
.vscode
.claude
.claude
# Unity WebGL 產物一律放在 public/Build/<buildId>/ 底下(見 scripts/deploy-unity-build.mjs)。
# 舊的無版本檔名會造成「新 loader + 舊 wasm」的版本錯配,擋掉避免手滑複製回來。
/public/Build/Build.loader.js
/public/Build/Build.data.unityweb
/public/Build/Build.framework.unityweb
/public/Build/Build.wasm.unityweb
27 changes: 26 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,32 @@ When in doubt for new contract code, mirror `useTradeOrder.ts` — it has the mo

### Unity WebGL bridge

The Unity build is served from `public/Build/` and loaded on `/battle` (`src/pages/battle.tsx`) via `react-unity-webgl`. `vercel.json` injects `Content-Encoding: gzip|br` and `Content-Type: application/wasm` headers for files under `/Build/`.
The Unity build is served from `public/Build/<buildId>/` and loaded on `/battle` (`src/pages/battle.tsx`) via `react-unity-webgl`.

**Build artifacts are versioned by directory — never put them back at a fixed path.** The four files (`Build.loader.js`, `Build.data.unityweb`, `Build.framework.unityweb`, `Build.wasm.unityweb`) are four independent cache entries. With fixed filenames a returning player could get a *new loader + old wasm*, and the mismatch blows up during wasm instantiation — stuck at 90% with `Not implemented: Class::FromIl2CppType` / `RangeError: Maximum call stack size exceeded`, only fixable by clearing site data. A per-build directory makes the four move together.

To ship a new Unity build:

```bash
pnpm deploy:unity <unity-build-output-dir> # copies + renames into public/Build/<buildId>/, rewrites version.json
git add public/Build && git commit && git push
```

`scripts/deploy-unity-build.mjs` derives `buildId` as `<YYYYMMDD>-<HHMM>-<git short sha>`, normalises the extensions, and prunes all but the newest 3 version directories (`--keep N` to change).

Caching contract, defined in both `vercel.json` and `next.config.js` (keep them in sync):

| Path | `Cache-Control` |
|---|---|
| `/Build/<buildId>/*` | `public, max-age=31536000, immutable` |
| `/Build/version.json` | `no-store, must-revalidate` |
| `/Build/*.unityweb` | (also gets `Content-Encoding: br`) |

`version.json` must stay uncached — it is the lookup that tells the client which `buildId` is current. `useUnityBuildVersion` fetches it with `cache: "no-store"`, and `BattlePage` does not mount `UnityGame` until it resolves, because `useUnityContext` freezes its URLs in a `useRef` on first render (changing them after mount is a no-op; `key={buildId}` forces a remount).

`battle.tsx` also passes `companyName` / `productName` / `productVersion: buildId`. Unity's own IndexedDB cache (`webGLDataCaching`) namespaces entries by those three and evicts entries whose `version` differs from `productVersion` — without them every build shares the default `"1.0"` and stale 43 MB `data` blobs are never evicted.

Do not set `Content-Type: application/wasm` on these files, and do not let the framework file keep a `.js` in its name: both types are in Vercel's CDN auto-compression MIME list, which would re-compress our already-Brotli payload and break decoding. The neutral `.unityweb` extension avoids this.

Web → Unity uses `unityContext.sendMessage(gameObject, method, payload)`:
- `Web.SetCardDeck(deckJson)` — read from `localStorage["battleDeck"]` set by `/platform`.
Expand Down
13 changes: 13 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,26 @@ const nextConfig = {
// MIME 清單內,宣告該型別會讓 CDN 接手 encoding,檔案超過約 11MiB 後我們的
// Content-Encoding 反而會被丟掉(wasm 從 10.5MiB 長到 11.1MiB 時就踩到了)。
// .unityweb 不在該清單內,Vercel 會原樣送出。
//
// 快取策略:產物放在 /Build/<buildId>/ 版本目錄下,內容不會再變,可以
// immutable 存一年;version.json 是唯一的 no-store 檔案,用來查最新版本。
// 正式環境由 vercel.json 提供同等設定(兩邊要一起維護)。
async headers() {
return [
{
source: "/:dir(Build)/:file*.unityweb",
headers: [{ key: "Content-Encoding", value: "br" }],
},
{
source: "/Build/:buildId(\\d{8}-\\d{4}-[^/]+)/:file*",
headers: [
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
],
},
{
source: "/Build/version.json",
headers: [{ key: "Cache-Control", value: "no-store, must-revalidate" }],
},
];
},

Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"start": "next start",
"deploy:unity": "node scripts/deploy-unity-build.mjs"
},
"dependencies": {
"@farcaster/miniapp-sdk": "^0.3.0",
Expand Down Expand Up @@ -41,4 +42,4 @@
"eslint-config-next": "^15.5.14",
"typescript": "5.9.3"
}
}
}
4 changes: 4 additions & 0 deletions public/Build/version.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"buildId": "20260902-0951-bd10359",
"builtAt": "2026-09-02T01:51:12.856Z"
}
110 changes: 110 additions & 0 deletions scripts/deploy-unity-build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/usr/bin/env node
/**
* 把 Unity WebGL build 產物搬進 public/Build/<buildId>/,並寫出 version.json。
*
* 為什麼要帶版本目錄:四個檔案(loader / data / framework / wasm)是四個獨立的
* 快取項目,檔名固定時各自的過期時間不同 —— 玩家可能拿到新 loader + 舊 wasm,
* 版本錯配就會在 90% 炸成 "Maximum call stack size exceeded"。
* 換成每次 build 一個新目錄後,四個檔案要嘛全新、要嘛全舊,不可能混搭。
*
* 用法:
* node scripts/deploy-unity-build.mjs <unity-build-output-dir> [--keep 3]
*
* <unity-build-output-dir> 是 Unity 吐出來的那個 Build/ 資料夾,裡面應有:
* Build.loader.js / Build.data.unityweb(或 .data)/ Build.framework.js.unityweb / Build.wasm.unityweb
*/
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";

const PUBLIC_BUILD = path.join(process.cwd(), "public", "Build");

function fail(msg) {
console.error(`\n✗ ${msg}\n`);
process.exit(1);
}

// --- 參數 -----------------------------------------------------------------
const args = process.argv.slice(2);
const srcDir = args.find((a) => !a.startsWith("--"));
if (!srcDir) fail("請指定 Unity build 輸出目錄:node scripts/deploy-unity-build.mjs <dir>");
if (!fs.existsSync(srcDir)) fail(`找不到目錄:${srcDir}`);

const keepIdx = args.indexOf("--keep");
const keep = keepIdx !== -1 ? Number(args[keepIdx + 1]) : 3;
if (!Number.isInteger(keep) || keep < 1) fail("--keep 需為 >= 1 的整數");

// --- 找出四個產物 ---------------------------------------------------------
// Unity 依 compressionFormat / decompressionFallback 會產生不同副檔名,
// 這裡用前綴比對,不寫死尾巴。
const srcFiles = fs.readdirSync(srcDir);
const pick = (re, label) => {
const hit = srcFiles.filter((f) => re.test(f));
if (hit.length === 0) fail(`在 ${srcDir} 找不到 ${label}(比對 ${re})`);
if (hit.length > 1) fail(`${label} 找到多個,無法判斷要用哪個:${hit.join(", ")}`);
return hit[0];
};

const loader = pick(/^Build\.loader\.js$/, "loader");
const data = pick(/^Build\.data(\.|$)/, "data");
const framework = pick(/^Build\.framework(\.|$)/, "framework");
const wasm = pick(/^Build\.wasm(\.|$)/, "wasm");

// --- buildId:git sha + 時間,可讀又唯一 ----------------------------------
let sha = "nogit";
try {
sha = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { encoding: "utf8" }).trim();
} catch {
/* 不在 git repo 也能跑 */
}
const now = new Date();
const p2 = (n) => String(n).padStart(2, "0");
const stamp =
`${now.getFullYear()}${p2(now.getMonth() + 1)}${p2(now.getDate())}` +
`-${p2(now.getHours())}${p2(now.getMinutes())}`;
const buildId = `${stamp}-${sha}`;

const destDir = path.join(PUBLIC_BUILD, buildId);
if (fs.existsSync(destDir)) fail(`${destDir} 已存在,請先刪除或等一分鐘再跑`);
fs.mkdirSync(destDir, { recursive: true });

// --- 複製並正規化檔名 -----------------------------------------------------
// framework / wasm / data 一律落地為 .unityweb:
// Vercel CDN 會依副檔名推導 Content-Type,含 .js 會被判為 javascript、
// .wasm 會被判為 application/wasm —— 兩者都在 CDN 自動壓縮的 MIME 清單內,
// 會在我們已經是 Brotli 的內容上再壓一層,瀏覽器解一層後仍是壓縮資料。
// .unityweb 不在清單內,Vercel 原樣送出。
const copies = [
[loader, "Build.loader.js"],
[data, "Build.data.unityweb"],
[framework, "Build.framework.unityweb"],
[wasm, "Build.wasm.unityweb"],
];

for (const [from, to] of copies) {
fs.copyFileSync(path.join(srcDir, from), path.join(destDir, to));
const mb = (fs.statSync(path.join(destDir, to)).size / 1024 / 1024).toFixed(1);
console.log(` ${from} → ${buildId}/${to} (${mb} MB)`);
}

// --- version.json:唯一一個 no-store 的檔案 -------------------------------
fs.writeFileSync(
path.join(PUBLIC_BUILD, "version.json"),
JSON.stringify({ buildId, builtAt: now.toISOString() }, null, 2) + "\n",
);

// --- 清掉過舊的版本目錄(保留最近 N 個,含這次) --------------------------
const versionDirs = fs
.readdirSync(PUBLIC_BUILD, { withFileTypes: true })
.filter((e) => e.isDirectory() && /^\d{8}-\d{4}-/.test(e.name))
.map((e) => e.name)
.sort();

const stale = versionDirs.slice(0, Math.max(0, versionDirs.length - keep));
for (const dir of stale) {
fs.rmSync(path.join(PUBLIC_BUILD, dir), { recursive: true, force: true });
console.log(` 🗑 移除舊版本 ${dir}`);
}

console.log(`\n✓ buildId = ${buildId}`);
console.log(` 下一步:git add public/Build && git commit && push\n`);
64 changes: 64 additions & 0 deletions src/hooks/useUnityBuildVersion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useEffect, useState } from "react";

/**
* Unity build 的版本識別。
*
* 為什麼要有這個:loader / data / framework / wasm 是四個獨立的快取項目。
* 過去四個檔名固定(/Build/Build.wasm.unityweb …),瀏覽器對每個檔案各自計算
* 過期時間,於是新版推上去後玩家可能拿到「新 loader + 舊 wasm」——
* 版本錯配讓 wasm 實例化失敗,卡在 90% 並拋出
* `Not implemented: Class::FromIl2CppType` / `Maximum call stack size exceeded`,
* 非得清快取才能救。
*
* 現在改成每次 build 一個 /Build/<buildId>/ 目錄,四個檔案同進同出,不可能混搭。
* version.json 是唯一一個 no-store 的檔案,用來問「現在最新是哪一版」。
*/
export type UnityBuildVersion = {
/** 例:20260902-0951-bd10359 */
buildId: string;
builtAt?: string;
};

type State =
| { status: "loading"; version: null; error: null }
| { status: "ready"; version: UnityBuildVersion; error: null }
| { status: "error"; version: null; error: string };

export function useUnityBuildVersion(): State {
const [state, setState] = useState<State>({
status: "loading",
version: null,
error: null,
});

useEffect(() => {
let cancelled = false;

// cache: "no-store" 是這整套機制的關鍵:這個檔案一旦被快取,
// 玩家就會繼續被指向舊的 buildId,等於白做。
fetch("/Build/version.json", { cache: "no-store" })
.then((res) => {
if (!res.ok) throw new Error(`version.json responded ${res.status}`);
return res.json() as Promise<UnityBuildVersion>;
})
.then((version) => {
if (cancelled) return;
if (!version?.buildId) throw new Error("version.json is missing buildId");
setState({ status: "ready", version, error: null });
})
.catch((err: unknown) => {
if (cancelled) return;
setState({
status: "error",
version: null,
error: err instanceof Error ? err.message : String(err),
});
});

return () => {
cancelled = true;
};
}, []);

return state;
}
2 changes: 2 additions & 0 deletions src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const en: Translation = {
stamina: "Stamina",
},
rewardClaimFailed: "Reward claim failed: {{message}}",
loading: "Loading Game...",
versionFailed: "Failed to load game version: {{message}}",
},
floatingMenu: {
openAriaLabel: "Open info menu",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface Translation {
stamina: string
}
rewardClaimFailed: string
loading: string
versionFailed: string
}
floatingMenu: {
openAriaLabel: string
Expand Down Expand Up @@ -207,6 +209,8 @@ const zhTW: Translation = {
stamina: "體力",
},
rewardClaimFailed: "領獎流程失敗:{{message}}",
loading: "Loading Game...",
versionFailed: "無法取得遊戲版本:{{message}}",
},
floatingMenu: {
openAriaLabel: "開啟資訊選單",
Expand Down
Loading