diff --git a/.gitignore b/.gitignore index e207ebe..e2c6048 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,10 @@ yarn-error.log* # ide .vscode -.claude \ No newline at end of file +.claude +# Unity WebGL 產物一律放在 public/Build// 底下(見 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 diff --git a/CLAUDE.md b/CLAUDE.md index b7c6040..9ca0a85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//` 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 # copies + renames into public/Build//, rewrites version.json +git add public/Build && git commit && git push +``` + +`scripts/deploy-unity-build.mjs` derives `buildId` as `--`, 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//*` | `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`. diff --git a/next.config.js b/next.config.js index a5e2b41..9de0a8e 100644 --- a/next.config.js +++ b/next.config.js @@ -13,6 +13,9 @@ const nextConfig = { // MIME 清單內,宣告該型別會讓 CDN 接手 encoding,檔案超過約 11MiB 後我們的 // Content-Encoding 反而會被丟掉(wasm 從 10.5MiB 長到 11.1MiB 時就踩到了)。 // .unityweb 不在該清單內,Vercel 會原樣送出。 + // + // 快取策略:產物放在 /Build// 版本目錄下,內容不會再變,可以 + // immutable 存一年;version.json 是唯一的 no-store 檔案,用來查最新版本。 // 正式環境由 vercel.json 提供同等設定(兩邊要一起維護)。 async headers() { return [ @@ -20,6 +23,16 @@ const nextConfig = { 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" }], + }, ]; }, diff --git a/package.json b/package.json index 0f948c0..ca9a02d 100644 --- a/package.json +++ b/package.json @@ -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", @@ -41,4 +42,4 @@ "eslint-config-next": "^15.5.14", "typescript": "5.9.3" } -} \ No newline at end of file +} diff --git a/public/Build/Build.data.unityweb b/public/Build/20260902-0951-bd10359/Build.data.unityweb similarity index 100% rename from public/Build/Build.data.unityweb rename to public/Build/20260902-0951-bd10359/Build.data.unityweb diff --git a/public/Build/Build.framework.unityweb b/public/Build/20260902-0951-bd10359/Build.framework.unityweb similarity index 100% rename from public/Build/Build.framework.unityweb rename to public/Build/20260902-0951-bd10359/Build.framework.unityweb diff --git a/public/Build/Build.loader.js b/public/Build/20260902-0951-bd10359/Build.loader.js similarity index 100% rename from public/Build/Build.loader.js rename to public/Build/20260902-0951-bd10359/Build.loader.js diff --git a/public/Build/Build.wasm.unityweb b/public/Build/20260902-0951-bd10359/Build.wasm.unityweb similarity index 100% rename from public/Build/Build.wasm.unityweb rename to public/Build/20260902-0951-bd10359/Build.wasm.unityweb diff --git a/public/Build/version.json b/public/Build/version.json new file mode 100644 index 0000000..bc4a567 --- /dev/null +++ b/public/Build/version.json @@ -0,0 +1,4 @@ +{ + "buildId": "20260902-0951-bd10359", + "builtAt": "2026-09-02T01:51:12.856Z" +} diff --git a/scripts/deploy-unity-build.mjs b/scripts/deploy-unity-build.mjs new file mode 100644 index 0000000..6a76c1f --- /dev/null +++ b/scripts/deploy-unity-build.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +/** + * 把 Unity WebGL build 產物搬進 public/Build//,並寫出 version.json。 + * + * 為什麼要帶版本目錄:四個檔案(loader / data / framework / wasm)是四個獨立的 + * 快取項目,檔名固定時各自的過期時間不同 —— 玩家可能拿到新 loader + 舊 wasm, + * 版本錯配就會在 90% 炸成 "Maximum call stack size exceeded"。 + * 換成每次 build 一個新目錄後,四個檔案要嘛全新、要嘛全舊,不可能混搭。 + * + * 用法: + * node scripts/deploy-unity-build.mjs [--keep 3] + * + * 是 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 "); +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`); diff --git a/src/hooks/useUnityBuildVersion.ts b/src/hooks/useUnityBuildVersion.ts new file mode 100644 index 0000000..2eb5654 --- /dev/null +++ b/src/hooks/useUnityBuildVersion.ts @@ -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// 目錄,四個檔案同進同出,不可能混搭。 + * 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({ + 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; + }) + .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; +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 72087eb..b8144f1 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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", diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index 43f70d2..23a4198 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -6,6 +6,8 @@ export interface Translation { stamina: string } rewardClaimFailed: string + loading: string + versionFailed: string } floatingMenu: { openAriaLabel: string @@ -207,6 +209,8 @@ const zhTW: Translation = { stamina: "體力", }, rewardClaimFailed: "領獎流程失敗:{{message}}", + loading: "Loading Game...", + versionFailed: "無法取得遊戲版本:{{message}}", }, floatingMenu: { openAriaLabel: "開啟資訊選單", diff --git a/src/pages/battle.tsx b/src/pages/battle.tsx index 8ef454f..28e92c6 100644 --- a/src/pages/battle.tsx +++ b/src/pages/battle.tsx @@ -11,6 +11,7 @@ import { FloatingMenuButton } from "../components/FloatingMenuButton"; import { InfoMenuModal } from "../components/InfoMenuModal"; import { ExitGameButton } from "../components/ExitGameButton"; import { useInfoPanelLayout } from "../hooks/useInfoPanelLayout"; +import { useUnityBuildVersion } from "../hooks/useUnityBuildVersion"; import { useRouter } from "next/router"; type RewardToastData = { @@ -58,11 +59,67 @@ function RewardToast({ data, onClose }: { data: RewardToastData; onClose: () => /** 「離開遊戲」的落點:官網的 Aurayale 介紹頁。 */ const EXIT_GAME_ROUTE = "/aurayale"; +/** + * Unity build 載入前的閘門。 + * + * 先問 /Build/version.json 拿到 buildId,才掛載真正的遊戲元件 —— 因為 + * react-unity-webgl 的 useUnityContext 是用 useRef 在首次 render 就把四個 URL + * 定住的,mount 之後再改 URL 不會生效。用 key={buildId} 讓 buildId 變動時 + * 整棵子樹重建。 + */ export default function BattlePage() { + const { t } = useTranslation(); + const { status, version, error } = useUnityBuildVersion(); + + if (status === "loading") { + return {t("battle.loading")}; + } + + if (status === "error") { + // 拿不到 version.json 就無從得知該載哪一版;硬猜只會重蹈版本錯配的覆轍。 + return ( + + {t("battle.versionFailed", { message: error })} + + ); + } + + return ; +} + +/** 遊戲載入 / 錯誤時共用的滿版底圖。 */ +function BattleBackdrop({ children }: { children: React.ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} + +function UnityGame({ buildId }: { buildId: string }) { const { t } = useTranslation(); const router = useRouter(); const [pendingDeck, setPendingDeck] = useState(null); const { unityProvider, isLoaded, loadingProgression, sendMessage } = useUnityContext({ + // 四個檔案都放在 /Build// 底下,隨每次 build 換一個新目錄。 + // 這是「卡在 90%、清快取才能玩」的解法:檔名固定時,loader / data / + // framework / wasm 是四個各自過期的快取項目,可能湊出「新 loader + 舊 wasm」, + // 版本錯配就會在 wasm 實例化時炸掉。換成版本目錄後四者同進同出。 + // // 內容是 Brotli,副檔名用中性的 .unityweb,由 vercel.json / next.config.js // 送 Content-Encoding: br 讓瀏覽器解壓。 // @@ -71,11 +128,20 @@ export default function BattlePage() { // 而 javascript 在 CDN 的自動壓縮 MIME 清單內 —— 於是在我們已經是 Brotli // 的內容上再壓一層,瀏覽器解一層後仍是壓縮資料,導致 SyntaxError。 // 同理也不可設 Content-Type: application/wasm(wasm 也在該清單內)。 - // 重新 build 後複製檔案時,記得一併改名。 - loaderUrl: "/Build/Build.loader.js", - dataUrl: "/Build/Build.data.unityweb", - frameworkUrl: "/Build/Build.framework.unityweb", - codeUrl: "/Build/Build.wasm.unityweb", + // 這些改名由 scripts/deploy-unity-build.mjs 自動處理。 + loaderUrl: `/Build/${buildId}/Build.loader.js`, + dataUrl: `/Build/${buildId}/Build.data.unityweb`, + frameworkUrl: `/Build/${buildId}/Build.framework.unityweb`, + codeUrl: `/Build/${buildId}/Build.wasm.unityweb`, + + // Unity loader 內建的 IndexedDB 快取(webGLDataCaching)用 + // companyName + productName + productVersion 當作 cache 命名空間,並在 + // cleanUpCache() 時清掉 version 不符的舊項目。不帶 productVersion 的話 + // 全部版本共用 "1.0",舊的 data 檔會一直佔著快取不被淘汰。 + // 帶上 buildId,等於每次新 build 自動汰換掉上一版的快取。 + companyName: "MustaverseDigital", + productName: "Aurayale", + productVersion: buildId, }); const { user } = useUser(); diff --git a/vercel.json b/vercel.json index 8633b3c..8d2344e 100644 --- a/vercel.json +++ b/vercel.json @@ -1,21 +1,22 @@ { + "$schema": "https://openapi.vercel.sh/vercel.json", "headers": [ { - "source": "/Build/(.*\\.unityweb)", + "source": "/Build/:buildId([0-9]{8}-[0-9]{4}-[^/]+)/:file*", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + }, + { + "source": "/Build/version.json", "headers": [ - { - "key": "Content-Encoding", - "value": "br" - } + { "key": "Cache-Control", "value": "no-store, must-revalidate" } ] }, { - "source": "/Build/(.*)", + "source": "/Build/(.*\\.unityweb)", "headers": [ - { - "key": "Cache-Control", - "value": "public, max-age=86400" - } + { "key": "Content-Encoding", "value": "br" } ] } ]