diff --git a/plugins/gomoku-3d-ztools/.gitignore b/plugins/gomoku-3d-ztools/.gitignore
new file mode 100644
index 000000000..deaa2e176
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+src-ztools/dist/
+test-results/
+.DS_Store
+*.log
+*.tsbuildinfo
diff --git a/plugins/gomoku-3d-ztools/LICENSE b/plugins/gomoku-3d-ztools/LICENSE
new file mode 100644
index 000000000..88fd429ea
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 crper
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/plugins/gomoku-3d-ztools/NOTICE b/plugins/gomoku-3d-ztools/NOTICE
new file mode 100644
index 000000000..cd391bba2
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/NOTICE
@@ -0,0 +1,15 @@
+Gomoku 3D for ZTools
+
+This plugin is a ZTools port and adaptation of:
+ crper/gomoku-3d
+ https://github.com/crper/gomoku-3d
+
+Upstream baseline commit:
+ fa6e266e4e9135aebb80e1487de88a6438380cf7
+
+The upstream project is Copyright (c) 2026 crper and licensed under the MIT License.
+The original LICENSE text is included in this distribution.
+
+ZTools-specific adaptations include plugin manifest and lifecycle integration,
+host-backed persistence, native save-dialog export, packaging, validation and
+Electron end-to-end tests.
diff --git a/plugins/gomoku-3d-ztools/README.md b/plugins/gomoku-3d-ztools/README.md
new file mode 100644
index 000000000..b7771f820
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/README.md
@@ -0,0 +1,32 @@
+# 五子棋 3D · ZTools 插件
+
+这是 [`crper/gomoku-3d`](https://github.com/crper/gomoku-3d) 的 ZTools 插件移植版,基于上游提交 `fa6e266e4e9135aebb80e1487de88a6438380cf7`。
+
+## 功能
+
+- 15×15 五子棋,2D/3D 双视图
+- 简单、中等、困难、大师四档本地 AI
+- 落子、悬停、获胜与 AI 思考动效
+- 一整回合悔棋
+- 棋局回放与速度调节
+- SGF、JSON、TXT 棋谱导出
+- 战绩、连胜和成就
+- 中英文界面与 Web Audio 合成音效
+- ZTools 宿主存储、进入/退出生命周期和原生保存对话框适配
+
+## 开发
+
+```bash
+npm install
+npm run dev
+npm test
+npm run build
+npm run validate:plugin
+npm run test:e2e
+```
+
+可安装插件目录为 `src-ztools/`,生产页面位于 `src-ztools/dist/`。
+
+## 许可与来源
+
+本项目保留上游 MIT 许可证,详见 `LICENSE` 与 `NOTICE`。上游原始说明保存在 `README.upstream.md` 和 `README.upstream.zh.md`。
diff --git a/plugins/gomoku-3d-ztools/README.upstream.md b/plugins/gomoku-3d-ztools/README.upstream.md
new file mode 100644
index 000000000..54462f64c
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/README.upstream.md
@@ -0,0 +1,143 @@
+
+
+# ⚫⚪ Gomoku 3D
+
+**A 3D Gomoku (Five-in-a-Row) game with an AI opponent, replay & game-record export — built with React, Three.js & TypeScript.**
+
+[](https://opensource.org/licenses/MIT)
+[](https://react.dev/)
+[](https://www.typescriptlang.org/)
+[](https://vitejs.dev/)
+[](https://threejs.org/)
+
+🎮 **Live Demo:**
+
+[English](./README.md) · [简体中文](./README.zh.md)
+
+
+
+---
+
+## ✨ Features
+
+- **15×15 Gomoku (Five-in-a-Row)** rendered on an interactive 3D board.
+- **AI opponent** with four difficulties — Easy / Medium / Hard / **Master** (a depth-limited alpha-beta negamax search).
+- **Dual view modes** — a locked 2D top-down board for clean play, and a free-orbit 3D view, with a smooth camera tween between them.
+- **Juicy animations** — stone drop-bounce with dust ripple, hover breathing preview, last-move pulse, victory line shimmer & hop, AI-thinking glow. All respect `prefers-reduced-motion`.
+- **Synthesized sound** via the Web Audio API — place / hover / undo / victory / defeat / achievement cues, with a mute toggle. Zero audio asset files.
+- **Game stats & achievements** persisted to `localStorage` (wins/losses/draws, win streaks, speed-win, beat-hard, etc.).
+- **Undo** a full round (your move + the AI's reply).
+- **Replay** any game — step forward/back, jump to any move, play/pause with 0.5×–4× speed, draggable progress bar. A read-only scrubbing mode that leaves the live game untouched.
+- **Game-record export** — save a finished or in-progress game as **SGF** (the de-facto standard for Go/Gomoku/Renju software), **JSON**, or human-readable **TXT**, with a custom filename.
+- **Fully responsive** — desktop sidebar layout and a mobile bottom action bar, no overlap or overflow.
+- **Keyboard shortcuts** and screen-reader-friendly controls (ARIA labels, `aria-live` move info, focus-managed dialogs).
+
+## 🧱 Tech Stack
+
+| Layer | Choice |
+| --- | --- |
+| UI framework | [React 18](https://react.dev/) + [TypeScript 5](https://www.typescriptlang.org/) (strict mode) |
+| Build tool | [Vite 5](https://vitejs.dev/) |
+| 3D rendering | [Three.js](https://threejs.org/) via [`@react-three/fiber`](https://github.com/pmndrs/react-three-fiber) + [`@react-three/drei`](https://github.com/pmndrs/drei) |
+| Styling | [Tailwind CSS](https://tailwindcss.com/) + hand-written shadcn/ui-style components |
+| Audio | Web Audio API (synthesized — no asset files, no audio dependency) |
+| State | React hooks — no external state library |
+
+## 🚀 Getting Started
+
+### Prerequisites
+
+- Node.js **≥ 18** (developed on 22)
+- npm
+
+### Install & run
+
+```bash
+git clone https://github.com/crper/gomoku-3d.git
+cd gomoku-3d
+npm install
+npm run dev # http://localhost:5173
+```
+
+### Scripts
+
+| Command | Description |
+| --- | --- |
+| `npm run dev` | Start the Vite dev server |
+| `npm run build` | Type-check (`tsc -b`) + production build to `dist/` |
+| `npm run preview` | Preview the production build locally |
+| `npm run typecheck` | Run `tsc --noEmit` |
+
+## 🧪 Tests
+
+Tests are plain TypeScript under `scripts/` and run with [esbuild](https://esbuild.github.io/) bundling + Node (no test runner dependency):
+
+```bash
+# Engine + AI logic (20 cases)
+./node_modules/.bin/esbuild scripts/engine.test.ts --bundle --platform=node --format=esm --outfile=/tmp/e.mjs && node /tmp/e.mjs
+
+# AI strength matrix
+./node_modules/.bin/esbuild scripts/strength.test.ts --bundle --platform=node --format=esm --outfile=/tmp/s.mjs && node /tmp/s.mjs
+
+# Replay & export (SGF/JSON/TXT) — 37 cases
+./node_modules/.bin/esbuild scripts/replay.test.ts --bundle --platform=node --format=esm --outfile=/tmp/r.mjs && node /tmp/r.mjs
+
+# 50-game self-play simulation (no stalls / illegal moves)
+./node_modules/.bin/esbuild scripts/sim.ts --bundle --platform=node --format=esm --outfile=/tmp/sim.mjs && node /tmp/sim.mjs
+```
+
+## 🎯 How to Play
+
+- Black moves first. Click any intersection to place your stone.
+- Connect **five** of your stones in a row (horizontally, vertically, or diagonally) to win.
+- Use the sidebar (desktop) or bottom bar (mobile) to switch difficulty, swap who plays first, toggle 2D/3D, undo, mute, replay, or export.
+
+### Keyboard shortcuts (during replay)
+
+| Key | Action |
+| --- | --- |
+| `←` / `→` | Previous / next move |
+| `Space` | Play / pause |
+| `Home` / `End` | First / last move |
+| `Esc` | Exit replay |
+
+## 📁 Project Structure
+
+```
+src/
+├── App.tsx # Layout, state wiring, replay/export integration
+├── main.tsx # Entry
+├── components/
+│ ├── GameBoard.tsx # 3D board (R3F) + all stone/board animations
+│ ├── ReplayPanel.tsx # Replay transport controls
+│ ├── ExportDialog.tsx # SGF/JSON/TXT export modal
+│ └── ui/ # Hand-written shadcn/ui-style primitives
+├── hooks/
+│ ├── useGomoku.ts # Game state, AI scheduling, stats, sound
+│ ├── useReplay.ts # Read-only replay state machine
+│ └── useViewMode.ts # 2D/3D view persistence
+└── lib/
+ ├── gomoku/
+ │ ├── types.ts # Board, Move, Player, win detection
+ │ ├── engine.ts # Immutable game state + transitions
+ │ ├── ai.ts # Heuristic + alpha-beta search
+ │ ├── replay.ts # Move derivation & board-at-step
+ │ ├── export.ts # SGF/JSON/TXT serialization + download
+ │ └── stats.ts # Stats & achievements model
+ ├── audio/sfx.ts # Web Audio synthesized SFX
+ └── achievementIcons.ts
+```
+
+## 📦 Deployment
+
+A GitHub Actions workflow (`.github/workflows/deploy.yml`) builds the app and deploys `dist/` to **GitHub Pages** on every push to `main`. The Vite base path is derived from `GITHUB_REPOSITORY`, so it works for any repo name.
+
+## 📄 License
+
+[MIT](./LICENSE) © [crper](https://github.com/crper)
+
+
+
+Made with ⚛️ React · 🧊 Three.js · 💛 TypeScript
+
+
diff --git a/plugins/gomoku-3d-ztools/README.upstream.zh.md b/plugins/gomoku-3d-ztools/README.upstream.zh.md
new file mode 100644
index 000000000..70c7003c3
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/README.upstream.zh.md
@@ -0,0 +1,143 @@
+
+
+# ⚫⚪ Gomoku 3D · 五子棋 3D
+
+**一款带 AI 对手、棋局回放与棋谱导出的 3D 五子棋——基于 React、Three.js 与 TypeScript 构建。**
+
+[](https://opensource.org/licenses/MIT)
+[](https://react.dev/)
+[](https://www.typescriptlang.org/)
+[](https://vitejs.dev/)
+[](https://threejs.org/)
+
+🎮 **在线体验:**
+
+[English](./README.md) · [简体中文](./README.zh.md)
+
+
+
+---
+
+## ✨ 功能特性
+
+- **15×15 五子棋**,落子在可交互的 3D 棋盘交叉点上。
+- **AI 对手四档难度**——简单 / 中等 / 困难 / **大师**(深度受限的 alpha-beta negamax 搜索)。
+- **双视图模式**——2D 平面锁视角(清爽下棋)与 3D 自由轨道视角,切换有平滑相机补间。
+- **丰富动效**——落子弹跳 + 尘埃波纹、悬停呼吸预览、最后一手脉冲、胜利连线流光跳动、AI 思考微光;全部尊重 `prefers-reduced-motion` 降级。
+- **Web Audio 合成音效**——落子 / 悬停 / 悔棋 / 胜负 / 成就提示,可静音;**无音频文件、零音频依赖**。
+- **战绩与成就**持久化到 `localStorage`(胜/负/和、连胜、速胜、击败困难等)。
+- **悔棋**一整回合(你的一手 + AI 的应手)。
+- **棋局回放**——逐步前进 / 后退、跳转任意手、播放-暂停(0.5×–4× 调速)、可拖动进度条;只读观察模式,不影响实时对局。
+- **棋谱导出**——把已结束或进行中的对局导出为 **SGF**(围棋/五子棋/连珠软件通用格式)、**JSON** 或人类可读的 **TXT**,文件名可自定义。
+- **全面响应式**——桌面侧栏布局 + 移动端底部操作条,无重叠、无溢出。
+- **键盘快捷键**与无障碍支持(ARIA 标签、`aria-live` 当前手信息、焦点管理的对话框)。
+
+## 🧱 技术栈
+
+| 层 | 选型 |
+| --- | --- |
+| UI 框架 | [React 18](https://react.dev/) + [TypeScript 5](https://www.typescriptlang.org/)(strict 严格模式) |
+| 构建工具 | [Vite 5](https://vitejs.dev/) |
+| 3D 渲染 | [Three.js](https://threejs.org/),经 [`@react-three/fiber`](https://github.com/pmndrs/react-three-fiber) + [`@react-three/drei`](https://github.com/pmndrs/drei) |
+| 样式 | [Tailwind CSS](https://tailwindcss.com/) + 手写 shadcn/ui 风格组件 |
+| 音频 | Web Audio API(合成,无资源文件、无音频依赖) |
+| 状态 | React Hooks——无外部状态库 |
+
+## 🚀 快速开始
+
+### 前置要求
+
+- Node.js **≥ 18**(开发环境为 22)
+- npm
+
+### 安装与运行
+
+```bash
+git clone https://github.com/crper/gomoku-3d.git
+cd gomoku-3d
+npm install
+npm run dev # http://localhost:5173
+```
+
+### 脚本
+
+| 命令 | 说明 |
+| --- | --- |
+| `npm run dev` | 启动 Vite 开发服务器 |
+| `npm run build` | 类型检查(`tsc -b`)+ 生产构建到 `dist/` |
+| `npm run preview` | 本地预览生产构建 |
+| `npm run typecheck` | 运行 `tsc --noEmit` |
+
+## 🧪 测试
+
+`scripts/` 下的测试是纯 TypeScript,用 [esbuild](https://esbuild.github.io/) 打包后由 Node 运行(不依赖测试框架):
+
+```bash
+# 引擎 + AI 逻辑(20 项)
+./node_modules/.bin/esbuild scripts/engine.test.ts --bundle --platform=node --format=esm --outfile=/tmp/e.mjs && node /tmp/e.mjs
+
+# AI 强度矩阵
+./node_modules/.bin/esbuild scripts/strength.test.ts --bundle --platform=node --format=esm --outfile=/tmp/s.mjs && node /tmp/s.mjs
+
+# 回放与导出(SGF/JSON/TXT)— 37 项
+./node_modules/.bin/esbuild scripts/replay.test.ts --bundle --platform=node --format=esm --outfile=/tmp/r.mjs && node /tmp/r.mjs
+
+# 50 局自对弈模拟(无卡死、无非法落子)
+./node_modules/.bin/esbuild scripts/sim.ts --bundle --platform=node --format=esm --outfile=/tmp/sim.mjs && node /tmp/sim.mjs
+```
+
+## 🎯 玩法
+
+- 黑棋先行。点击任意交叉点落子。
+- 横、竖、斜任意方向连成 **五子** 即胜。
+- 通过侧栏(桌面)或底栏(移动端)切换难度、先手、2D/3D 视图,以及悔棋、静音、回放、导出。
+
+### 回放键盘快捷键
+
+| 键 | 动作 |
+| --- | --- |
+| `←` / `→` | 上一手 / 下一手 |
+| `空格` | 播放 / 暂停 |
+| `Home` / `End` | 首手 / 末手 |
+| `Esc` | 退出回放 |
+
+## 📁 项目结构
+
+```
+src/
+├── App.tsx # 布局、状态接线、回放/导出集成
+├── main.tsx # 入口
+├── components/
+│ ├── GameBoard.tsx # 3D 棋盘(R3F)+ 棋子/棋盘全部动效
+│ ├── ReplayPanel.tsx # 回放传输控件
+│ ├── ExportDialog.tsx # SGF/JSON/TXT 导出对话框
+│ └── ui/ # 手写 shadcn/ui 风格基础组件
+├── hooks/
+│ ├── useGomoku.ts # 对局状态、AI 调度、战绩、音效
+│ ├── useReplay.ts # 只读回放状态机
+│ └── useViewMode.ts # 2D/3D 视图持久化
+└── lib/
+ ├── gomoku/
+ │ ├── types.ts # 棋盘、着法、玩家、胜判定
+ │ ├── engine.ts # 不可变对局状态 + 状态转换
+ │ ├── ai.ts # 启发式 + alpha-beta 搜索
+ │ ├── replay.ts # 着法推导与"第 k 手盘面"
+ │ ├── export.ts # SGF/JSON/TXT 序列化 + 下载
+ │ └── stats.ts # 战绩与成就模型
+ ├── audio/sfx.ts # Web Audio 合成音效
+ └── achievementIcons.ts
+```
+
+## 📦 部署
+
+仓库内置 GitHub Actions 工作流(`.github/workflows/deploy.yml`),每次推送到 `main` 会自动构建并把 `dist/` 部署到 **GitHub Pages**。Vite 的 base 路径由 `GITHUB_REPOSITORY` 动态推导,因此任意仓库名都能正确解析资源。
+
+## 📄 许可证
+
+[MIT](./LICENSE) © [crper](https://github.com/crper)
+
+
+
+用 ⚛️ React · 🧊 Three.js · 💛 TypeScript 打造
+
+
diff --git a/plugins/gomoku-3d-ztools/index.html b/plugins/gomoku-3d-ztools/index.html
new file mode 100644
index 000000000..b8083e8ed
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ 五子棋 · Gomoku 3D
+
+
+
+
+
+
diff --git a/plugins/gomoku-3d-ztools/package-lock.json b/plugins/gomoku-3d-ztools/package-lock.json
new file mode 100644
index 000000000..9de64a638
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/package-lock.json
@@ -0,0 +1,3680 @@
+{
+ "name": "gomoku-3d-ztools",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "gomoku-3d-ztools",
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-label": "^2.1.15",
+ "@radix-ui/react-separator": "^1.1.15",
+ "@radix-ui/react-slider": "^1.4.7",
+ "@radix-ui/react-slot": "^1.3.3",
+ "@radix-ui/react-switch": "^1.3.7",
+ "@react-three/drei": "^10.7.7",
+ "@react-three/fiber": "^9.7.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "i18next": "^26.3.6",
+ "lucide-react": "^1.28.0",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8",
+ "react-i18next": "^17.0.11",
+ "tailwind-merge": "^3.6.0",
+ "three": "^0.185.1"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.55.0",
+ "@tailwindcss/postcss": "^4.3.3",
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.4",
+ "@types/three": "^0.185.1",
+ "@vitejs/plugin-react": "^6.0.5",
+ "esbuild": "^0.28.1",
+ "postcss": "^8.5.25",
+ "tailwindcss": "^4.3.3",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^7.0.2",
+ "vite": "^8.2.0"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmmirror.com/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@emnapi/core": {
+ "version": "2.0.0-alpha.3",
+ "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
+ "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "2.0.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "2.0.0-alpha.3",
+ "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz",
+ "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
+ "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@mediapipe/tasks-vision": {
+ "version": "0.10.17",
+ "resolved": "https://registry.npmmirror.com/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz",
+ "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@monogrid/gainmap-js": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmmirror.com/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz",
+ "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==",
+ "license": "MIT",
+ "dependencies": {
+ "promise-worker-transferable": "^1.0.4"
+ },
+ "peerDependencies": {
+ "three": ">= 0.159.0"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
+ "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3",
+ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.142.0",
+ "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.142.0.tgz",
+ "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
+ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@radix-ui/number": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.3.tgz",
+ "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/primitive": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.7.tgz",
+ "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-collection": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.15.tgz",
+ "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.2.2",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-slot": "1.3.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
+ "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.2.2.tgz",
+ "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-direction": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.4.tgz",
+ "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-label": {
+ "version": "2.1.15",
+ "resolved": "https://registry.npmmirror.com/@radix-ui/react-label/-/react-label-2.1.15.tgz",
+ "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.10",
+ "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
+ "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.3.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-separator": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmmirror.com/@radix-ui/react-separator/-/react-separator-1.1.15.tgz",
+ "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slider": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmmirror.com/@radix-ui/react-slider/-/react-slider-1.4.7.tgz",
+ "integrity": "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.3",
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-collection": "1.1.15",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.2.2",
+ "@radix-ui/react-direction": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-use-controllable-state": "1.2.6",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "@radix-ui/react-use-previous": "1.1.4",
+ "@radix-ui/react-use-size": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
+ "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-switch": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmmirror.com/@radix-ui/react-switch/-/react-switch-1.3.7.tgz",
+ "integrity": "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.2.2",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-use-controllable-state": "1.2.6",
+ "@radix-ui/react-use-size": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz",
+ "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-use-effect-event": "0.0.5",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
+ "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
+ "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-previous": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz",
+ "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-size": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz",
+ "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@react-three/drei": {
+ "version": "10.7.7",
+ "resolved": "https://registry.npmmirror.com/@react-three/drei/-/drei-10.7.7.tgz",
+ "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.26.0",
+ "@mediapipe/tasks-vision": "0.10.17",
+ "@monogrid/gainmap-js": "^3.0.6",
+ "@use-gesture/react": "^10.3.1",
+ "camera-controls": "^3.1.0",
+ "cross-env": "^7.0.3",
+ "detect-gpu": "^5.0.56",
+ "glsl-noise": "^0.0.0",
+ "hls.js": "^1.5.17",
+ "maath": "^0.10.8",
+ "meshline": "^3.3.1",
+ "stats-gl": "^2.2.8",
+ "stats.js": "^0.17.0",
+ "suspend-react": "^0.1.3",
+ "three-mesh-bvh": "^0.8.3",
+ "three-stdlib": "^2.35.6",
+ "troika-three-text": "^0.52.4",
+ "tunnel-rat": "^0.1.2",
+ "use-sync-external-store": "^1.4.0",
+ "utility-types": "^3.11.0",
+ "zustand": "^5.0.1"
+ },
+ "peerDependencies": {
+ "@react-three/fiber": "^9.0.0",
+ "react": "^19",
+ "react-dom": "^19",
+ "three": ">=0.159"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@react-three/fiber": {
+ "version": "9.7.0",
+ "resolved": "https://registry.npmmirror.com/@react-three/fiber/-/fiber-9.7.0.tgz",
+ "integrity": "sha512-EWm9FwcaOZQu/ExFW5rggoCMM1NJet5YbxVxKaOE+KSncrjU0Wx7017qSyGFvupviK89nMYGCWU3BIK4dI1clw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.17.8",
+ "@types/webxr": "*",
+ "base64-js": "^1.5.1",
+ "buffer": "^6.0.3",
+ "its-fine": "^2.0.0",
+ "react-use-measure": "^2.1.7",
+ "scheduler": "^0.27.0",
+ "suspend-react": "^0.1.3",
+ "use-sync-external-store": "^1.4.0",
+ "zustand": "^5.0.3"
+ },
+ "peerDependencies": {
+ "expo": ">=43.0",
+ "expo-asset": ">=8.4",
+ "expo-file-system": ">=11.0",
+ "expo-gl": ">=11.0",
+ "react": ">=19 <19.3",
+ "react-dom": ">=19 <19.3",
+ "react-native": ">=0.78",
+ "three": ">=0.156"
+ },
+ "peerDependenciesMeta": {
+ "expo": {
+ "optional": true
+ },
+ "expo-asset": {
+ "optional": true
+ },
+ "expo-file-system": {
+ "optional": true
+ },
+ "expo-gl": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz",
+ "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz",
+ "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz",
+ "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz",
+ "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz",
+ "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz",
+ "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz",
+ "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz",
+ "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz",
+ "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz",
+ "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz",
+ "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz",
+ "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz",
+ "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "2.0.0-alpha.3",
+ "@emnapi/runtime": "2.0.0-alpha.3",
+ "@napi-rs/wasm-runtime": "^1.2.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz",
+ "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz",
+ "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.3.tgz",
+ "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.24.1",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+ "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-x64": "4.3.3",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+ "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+ "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+ "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+ "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+ "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+ "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+ "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+ "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+ "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+ "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+ "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
+ "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/postcss": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmmirror.com/@tailwindcss/postcss/-/postcss-4.3.3.tgz",
+ "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "postcss": "^8.5.16",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmmirror.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/draco3d": {
+ "version": "1.4.10",
+ "resolved": "https://registry.npmmirror.com/@types/draco3d/-/draco3d-1.4.10.tgz",
+ "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/offscreencanvas": {
+ "version": "2019.7.3",
+ "resolved": "https://registry.npmmirror.com/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
+ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.18",
+ "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.18.tgz",
+ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
+ "devOptional": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/react-reconciler": {
+ "version": "0.28.9",
+ "resolved": "https://registry.npmmirror.com/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
+ "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*"
+ }
+ },
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/three": {
+ "version": "0.185.1",
+ "resolved": "https://registry.npmmirror.com/@types/three/-/three-0.185.1.tgz",
+ "integrity": "sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": ">=0.5.17",
+ "fflate": "~0.8.2",
+ "meshoptimizer": "~1.1.1"
+ }
+ },
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmmirror.com/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "license": "MIT"
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@use-gesture/core": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmmirror.com/@use-gesture/core/-/core-10.3.1.tgz",
+ "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
+ "license": "MIT"
+ },
+ "node_modules/@use-gesture/react": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmmirror.com/@use-gesture/react/-/react-10.3.1.tgz",
+ "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@use-gesture/core": "10.3.1"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
+ "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmmirror.com/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/camera-controls": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmmirror.com/camera-controls/-/camera-controls-3.1.2.tgz",
+ "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.0.0",
+ "npm": ">=10.5.1"
+ },
+ "peerDependencies": {
+ "three": ">=0.126.1"
+ }
+ },
+ "node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
+ "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cross-env": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmmirror.com/cross-env/-/cross-env-7.0.3.tgz",
+ "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.1"
+ },
+ "bin": {
+ "cross-env": "src/bin/cross-env.js",
+ "cross-env-shell": "src/bin/cross-env-shell.js"
+ },
+ "engines": {
+ "node": ">=10.14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/detect-gpu": {
+ "version": "5.0.70",
+ "resolved": "https://registry.npmmirror.com/detect-gpu/-/detect-gpu-5.0.70.tgz",
+ "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==",
+ "license": "MIT",
+ "dependencies": {
+ "webgl-constants": "^1.1.1"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/draco3d": {
+ "version": "1.5.7",
+ "resolved": "https://registry.npmmirror.com/draco3d/-/draco3d-1.5.7.tgz",
+ "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.24.5",
+ "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
+ "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/glsl-noise": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmmirror.com/glsl-noise/-/glsl-noise-0.0.0.tgz",
+ "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==",
+ "license": "MIT"
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/hls.js": {
+ "version": "1.6.16",
+ "resolved": "https://registry.npmmirror.com/hls.js/-/hls.js-1.6.16.tgz",
+ "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/html-parse-stringify": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
+ "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://locize.com"
+ }
+ },
+ "node_modules/i18next": {
+ "version": "26.3.6",
+ "resolved": "https://registry.npmmirror.com/i18next/-/i18next-26.3.6.tgz",
+ "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://www.locize.com/i18next"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.locize.com"
+ }
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": "^5 || ^6 || ^7"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
+ "node_modules/is-promise": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmmirror.com/is-promise/-/is-promise-2.2.2.tgz",
+ "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/its-fine": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/its-fine/-/its-fine-2.0.0.tgz",
+ "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react-reconciler": "^0.28.9"
+ },
+ "peerDependencies": {
+ "react": "^19.0.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "1.28.0",
+ "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-1.28.0.tgz",
+ "integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/maath": {
+ "version": "0.10.8",
+ "resolved": "https://registry.npmmirror.com/maath/-/maath-0.10.8.tgz",
+ "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/three": ">=0.134.0",
+ "three": ">=0.134.0"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/meshline": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmmirror.com/meshline/-/meshline-3.3.1.tgz",
+ "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/meshoptimizer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
+ "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.25",
+ "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/potpack": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/potpack/-/potpack-1.0.2.tgz",
+ "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
+ "license": "ISC"
+ },
+ "node_modules/promise-worker-transferable": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmmirror.com/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
+ "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "is-promise": "^2.1.0",
+ "lie": "^3.0.2"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/react-i18next": {
+ "version": "17.0.11",
+ "resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-17.0.11.tgz",
+ "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2",
+ "html-parse-stringify": "^4.0.1",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "i18next": ">= 26.2.0",
+ "react": ">= 16.8.0",
+ "typescript": "^5 || ^6 || ^7"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-use-measure": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmmirror.com/react-use-measure/-/react-use-measure-2.1.7.tgz",
+ "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.13",
+ "react-dom": ">=16.13"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.2.1.tgz",
+ "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.142.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.2.1",
+ "@rolldown/binding-darwin-arm64": "1.2.1",
+ "@rolldown/binding-darwin-x64": "1.2.1",
+ "@rolldown/binding-freebsd-x64": "1.2.1",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.1",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.1",
+ "@rolldown/binding-linux-arm64-musl": "1.2.1",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.1",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.1",
+ "@rolldown/binding-linux-x64-gnu": "1.2.1",
+ "@rolldown/binding-linux-x64-musl": "1.2.1",
+ "@rolldown/binding-openharmony-arm64": "1.2.1",
+ "@rolldown/binding-wasm32-wasi": "1.2.1",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.1",
+ "@rolldown/binding-win32-x64-msvc": "1.2.1"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stats-gl": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmmirror.com/stats-gl/-/stats-gl-2.4.2.tgz",
+ "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/three": "*",
+ "three": "^0.170.0"
+ },
+ "peerDependencies": {
+ "@types/three": "*",
+ "three": "*"
+ }
+ },
+ "node_modules/stats-gl/node_modules/three": {
+ "version": "0.170.0",
+ "resolved": "https://registry.npmmirror.com/three/-/three-0.170.0.tgz",
+ "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
+ "license": "MIT"
+ },
+ "node_modules/stats.js": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmmirror.com/stats.js/-/stats.js-0.17.0.tgz",
+ "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==",
+ "license": "MIT"
+ },
+ "node_modules/suspend-react": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmmirror.com/suspend-react/-/suspend-react-0.1.3.tgz",
+ "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=17.0"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
+ "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.3.tgz",
+ "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/three": {
+ "version": "0.185.1",
+ "resolved": "https://registry.npmmirror.com/three/-/three-0.185.1.tgz",
+ "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
+ "license": "MIT"
+ },
+ "node_modules/three-mesh-bvh": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmmirror.com/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz",
+ "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">= 0.159.0"
+ }
+ },
+ "node_modules/three-stdlib": {
+ "version": "2.36.1",
+ "resolved": "https://registry.npmmirror.com/three-stdlib/-/three-stdlib-2.36.1.tgz",
+ "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/draco3d": "^1.4.0",
+ "@types/offscreencanvas": "^2019.6.4",
+ "@types/webxr": "^0.5.2",
+ "draco3d": "^1.4.1",
+ "fflate": "^0.6.9",
+ "potpack": "^1.0.1"
+ },
+ "peerDependencies": {
+ "three": ">=0.128.0"
+ }
+ },
+ "node_modules/three-stdlib/node_modules/fflate": {
+ "version": "0.6.11",
+ "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.6.11.tgz",
+ "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/troika-three-text": {
+ "version": "0.52.5",
+ "resolved": "https://registry.npmmirror.com/troika-three-text/-/troika-three-text-0.52.5.tgz",
+ "integrity": "sha512-Ry3jRhic9pzcY4JduSvRRyDmVOSqEW19gT4vtK+aCiPNVcDlmkxvGG0YbFd36RTDq1wExOupXnvNF/j1oiHHDA==",
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.0.2",
+ "troika-three-utils": "^0.52.5",
+ "troika-worker-utils": "^0.52.0",
+ "webgl-sdf-generator": "1.1.1"
+ },
+ "peerDependencies": {
+ "three": ">=0.125.0"
+ }
+ },
+ "node_modules/troika-three-utils": {
+ "version": "0.52.5",
+ "resolved": "https://registry.npmmirror.com/troika-three-utils/-/troika-three-utils-0.52.5.tgz",
+ "integrity": "sha512-WsePbcX8RtfidRfsxK1eCZCjF81ZDzAKHH/evLs0hdV2wpoCb0vArGZHdzdOJrSS3k4zfdtbKDaBh8+phkrYnw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">=0.125.0"
+ }
+ },
+ "node_modules/troika-worker-utils": {
+ "version": "0.52.0",
+ "resolved": "https://registry.npmmirror.com/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
+ "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
+ "license": "MIT"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/tunnel-rat": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmmirror.com/tunnel-rat/-/tunnel-rat-0.1.2.tgz",
+ "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==",
+ "license": "MIT",
+ "dependencies": {
+ "zustand": "^4.3.2"
+ }
+ },
+ "node_modules/tunnel-rat/node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tw-animate-css": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmmirror.com/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
+ "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Wombosvideo"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmmirror.com/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/utility-types": {
+ "version": "3.11.0",
+ "resolved": "https://registry.npmmirror.com/utility-types/-/utility-types-3.11.0.tgz",
+ "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmmirror.com/vite/-/vite-8.2.0.tgz",
+ "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.23",
+ "rolldown": "~1.2.0",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/webgl-constants": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/webgl-constants/-/webgl-constants-1.1.1.tgz",
+ "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="
+ },
+ "node_modules/webgl-sdf-generator": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz",
+ "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
+ "license": "MIT"
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/zustand": {
+ "version": "5.0.14",
+ "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.14.tgz",
+ "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/plugins/gomoku-3d-ztools/package.json b/plugins/gomoku-3d-ztools/package.json
new file mode 100644
index 000000000..6ccb70a8c
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/package.json
@@ -0,0 +1,68 @@
+{
+ "name": "gomoku-3d-ztools",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "description": "由 crper/gomoku-3d 移植的 ZTools 3D 五子棋插件。",
+ "keywords": [
+ "ztools",
+ "gomoku",
+ "five-in-a-row",
+ "react",
+ "threejs",
+ "game",
+ "ai"
+ ],
+ "author": "crper, ZTools port contributors",
+ "license": "MIT",
+ "homepage": "https://github.com/crper/gomoku-3d",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/crper/gomoku-3d.git"
+ },
+ "overrides": {
+ "nanoid": "3.3.18"
+ },
+ "scripts": {
+ "dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview --host 127.0.0.1 --port 4173 --strictPort",
+ "typecheck": "tsc --noEmit",
+ "test": "rm -rf /tmp/gomoku-tests && esbuild scripts/engine.test.ts scripts/replay.test.ts scripts/strength.test.ts --bundle --platform=node --format=esm --outdir=/tmp/gomoku-tests --log-level=warning && node /tmp/gomoku-tests/engine.test.js && node /tmp/gomoku-tests/replay.test.js && node /tmp/gomoku-tests/strength.test.js",
+ "test:sim": "esbuild scripts/sim.ts --bundle --platform=node --format=esm --outfile=/tmp/gomoku-sim.mjs --log-level=warning && node /tmp/gomoku-sim.mjs",
+ "test:e2e": "playwright test --config=playwright.e2e.config.js",
+ "validate:plugin": "python3 /Users/zing/.ztools/ZVC/skill/develop-ztools-plugin/scripts/validate_plugin.py src-ztools"
+ },
+ "dependencies": {
+ "@radix-ui/react-label": "^2.1.15",
+ "@radix-ui/react-slot": "^1.3.3",
+ "@radix-ui/react-separator": "^1.1.15",
+ "@radix-ui/react-slider": "^1.4.7",
+ "@radix-ui/react-switch": "^1.3.7",
+ "@react-three/drei": "^10.7.7",
+ "@react-three/fiber": "^9.7.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "i18next": "^26.3.6",
+ "lucide-react": "^1.28.0",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8",
+ "react-i18next": "^17.0.11",
+ "tailwind-merge": "^3.6.0",
+ "three": "^0.185.1"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.55.0",
+ "@tailwindcss/postcss": "^4.3.3",
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.4",
+ "@types/three": "^0.185.1",
+ "@vitejs/plugin-react": "^6.0.5",
+ "esbuild": "^0.28.1",
+ "postcss": "^8.5.25",
+ "tailwindcss": "^4.3.3",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^7.0.2",
+ "vite": "^8.2.0"
+ }
+}
diff --git a/plugins/gomoku-3d-ztools/playwright.e2e.config.js b/plugins/gomoku-3d-ztools/playwright.e2e.config.js
new file mode 100644
index 000000000..8cd2ae952
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/playwright.e2e.config.js
@@ -0,0 +1,17 @@
+import { defineConfig } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './tests/e2e',
+ outputDir: './test-results/playwright',
+ timeout: 60_000,
+ expect: {
+ timeout: 20_000,
+ },
+ workers: 1,
+ fullyParallel: false,
+ reporter: [['list']],
+ use: {
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ },
+})
diff --git a/plugins/gomoku-3d-ztools/postcss.config.js b/plugins/gomoku-3d-ztools/postcss.config.js
new file mode 100644
index 000000000..c2ddf7482
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/postcss.config.js
@@ -0,0 +1,5 @@
+export default {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
diff --git a/plugins/gomoku-3d-ztools/scripts/engine.test.ts b/plugins/gomoku-3d-ztools/scripts/engine.test.ts
new file mode 100644
index 000000000..bd9791226
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/scripts/engine.test.ts
@@ -0,0 +1,131 @@
+import {
+ Board,
+ Move,
+ Player,
+ BLACK,
+ WHITE,
+ createBoard,
+ idx,
+ checkWinFrom,
+ isFull,
+} from "../src/lib/gomoku/types";
+import * as engine from "../src/lib/gomoku/engine";
+import { getAIMove } from "../src/lib/gomoku/ai";
+
+let pass = 0;
+let fail = 0;
+function assert(cond: boolean, msg: string) {
+ if (cond) {
+ pass++;
+ console.log(" ok -", msg);
+ } else {
+ fail++;
+ console.log("FAIL -", msg);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// 1. placeStone boundary defenses (no throws, returns input unchanged)
+// ---------------------------------------------------------------------------
+{
+ const g = engine.createGame();
+ assert(g.status === "playing" && g.currentPlayer === BLACK && g.history.length === 0, "createGame: empty board, BLACK to move");
+
+ // out of bounds
+ const oob = engine.placeStone(g, { x: -1, y: 0 }, BLACK);
+ assert(oob === g, "out-of-bounds move returns the same state (no throw)");
+ assert(oob.board[idx(7, 7)] === 0, "out-of-bounds move did not write a stone");
+
+ // not the current player's turn
+ const wrong = engine.placeStone(g, { x: 7, y: 7 }, WHITE);
+ assert(wrong === g, "wrong-player move returns the same state");
+
+ // normal move then repeat on the same cell
+ let g2 = engine.placeStone(g, { x: 7, y: 7 }, BLACK);
+ assert(g2.board[idx(7, 7)] === BLACK && g2.currentPlayer === WHITE, "valid move writes stone and passes turn");
+ const repeat = engine.placeStone(g2, { x: 7, y: 7 }, WHITE);
+ assert(repeat === g2, "repeat move on occupied cell returns the same state");
+
+ // game over guard
+ const over = { ...g2, status: "black_win" as const };
+ const afterOver = engine.placeStone(over, { x: 0, y: 0 }, WHITE);
+ assert(afterOver === over, "move after game over is ignored");
+}
+
+// ---------------------------------------------------------------------------
+// 2. undo round-trip (one full round -> empty board)
+// ---------------------------------------------------------------------------
+{
+ let g = engine.createGame();
+ g = engine.placeStone(g, { x: 7, y: 7 }, BLACK); // human
+ g = engine.placeStone(g, { x: 8, y: 8 }, WHITE); // AI reply
+ assert(g.history.length === 2, "two moves recorded two history entries");
+ assert(engine.canUndo(g), "canUndo true after a full round");
+
+ g = engine.undo(g); // revert the whole round
+ assert(g.moveCount === 0, "undo reverts a full round back to 0 moves");
+ assert(g.history.length === 0, "undo clears history");
+ assert(g.currentPlayer === BLACK, "undo returns to the human's turn (BLACK)");
+ assert(g.board.every((c) => c === 0), "undo leaves an empty board");
+ assert(!engine.canUndo(g), "canUndo false on empty board");
+
+ // undo on a finished game is a no-op
+ const finished = { ...engine.createGame(), status: "draw" as const };
+ assert(engine.undo(finished) === finished, "undo on finished game is a no-op");
+}
+
+// ---------------------------------------------------------------------------
+// 3. master getAIMove returns a legal move + a game terminates (no stall)
+// ---------------------------------------------------------------------------
+{
+ // empty board -> center
+ const empty = createBoard();
+ const m0 = getAIMove(empty, WHITE, "master");
+ assert(!!m0 && m0.x === 7 && m0.y === 7, "master: empty board -> center (7,7)");
+
+ // a forced block: BLACK has an open four, master (WHITE) must block an end
+ const b = createBoard();
+ for (let x = 3; x <= 6; x++) b[idx(x, 7)] = BLACK;
+ const mBlock = getAIMove(b, WHITE, "master");
+ const blocks =
+ !!mBlock &&
+ b[idx(mBlock.x, mBlock.y)] === 0 &&
+ ((mBlock.y === 7 && (mBlock.x === 2 || mBlock.x === 7)));
+ assert(blocks, "master blocks BLACK's open four at an open end");
+
+ // mid-game timing + legality
+ const mid = createBoard();
+ const seeds: [number, number, Player][] = [
+ [7, 7, BLACK], [7, 8, WHITE], [8, 7, BLACK], [6, 8, WHITE],
+ [8, 8, BLACK], [6, 7, WHITE], [9, 7, BLACK],
+ ];
+ for (const [x, y, p] of seeds) mid[idx(x, y)] = p;
+ const t0 = performance.now();
+ const mMid = getAIMove(mid, WHITE, "master");
+ const dt = performance.now() - t0;
+ assert(!!mMid && mid[idx(mMid.x, mMid.y)] === 0, "master returns a legal (empty) move on mid-game board");
+ assert(dt < 1000, `master move under 1s (took ${dt.toFixed(1)}ms)`);
+
+ // full master vs master game must terminate without illegal moves
+ const playMasterGame = () => {
+ const board: Board = createBoard();
+ let cur: Player = BLACK;
+ let moves = 0;
+ while (moves < 225) {
+ const m = getAIMove(board, cur, "master");
+ if (!m) break; // draw (full board)
+ if (board[idx(m.x, m.y)] !== 0) throw new Error("master returned an occupied cell");
+ board[idx(m.x, m.y)] = cur;
+ if (checkWinFrom(board, m.x, m.y, cur)) return { winner: cur, moves };
+ if (isFull(board)) return { winner: 0 as Player, moves };
+ cur = cur === BLACK ? WHITE : BLACK;
+ moves++;
+ }
+ throw new Error("master game did not terminate (possible stall)");
+ };
+ const r = playMasterGame();
+ assert(true, `master vs master terminated: winner=${r.winner} in ${r.moves} moves`);
+}
+
+console.log(`\n${pass} passed, ${fail} failed`);
+if (fail > 0) process.exit(1);
diff --git a/plugins/gomoku-3d-ztools/scripts/replay.test.ts b/plugins/gomoku-3d-ztools/scripts/replay.test.ts
new file mode 100644
index 000000000..c45faa9d5
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/scripts/replay.test.ts
@@ -0,0 +1,171 @@
+import { BLACK, WHITE, idx, createBoard } from "../src/lib/gomoku/types";
+import * as engine from "../src/lib/gomoku/engine";
+import { deriveMoves, boardAtStep, snapshotGame } from "../src/lib/gomoku/replay";
+import {
+ buildMeta,
+ toSGF,
+ toJSON,
+ toTXT,
+ sanitizeFileName,
+ defaultFileName,
+ resultToChinese,
+} from "../src/lib/gomoku/export";
+
+let pass = 0;
+let fail = 0;
+function assert(cond: boolean, msg: string) {
+ if (cond) {
+ pass++;
+ console.log(" ok -", msg);
+ } else {
+ fail++;
+ console.log("FAIL -", msg);
+ }
+}
+
+function pad2(n: number): string {
+ return String(n).padStart(2, "0");
+}
+function localDate(): string {
+ const d = new Date();
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
+}
+
+// ---------------------------------------------------------------------------
+// 1. deriveMoves / boardAtStep over a known 3-move game
+// black(7,7) -> white(8,8) -> black(7,6)
+// ---------------------------------------------------------------------------
+let game = engine.createGame();
+game = engine.placeStone(game, { x: 7, y: 7 }, BLACK);
+game = engine.placeStone(game, { x: 8, y: 8 }, WHITE);
+game = engine.placeStone(game, { x: 7, y: 6 }, BLACK);
+
+{
+ assert(game.moveCount === 3 && game.history.length === 3, "setup: 3 moves, 3 history entries");
+
+ const moves = deriveMoves(game);
+ assert(moves.length === 3, "deriveMoves returns 3 moves");
+ assert(
+ moves[0].index === 1 && moves[0].x === 7 && moves[0].y === 7 && moves[0].player === BLACK && moves[0].label === "黑",
+ "move 1 = black (7,7)"
+ );
+ assert(
+ moves[1].index === 2 && moves[1].x === 8 && moves[1].y === 8 && moves[1].player === WHITE && moves[1].label === "白",
+ "move 2 = white (8,8)"
+ );
+ assert(
+ moves[2].index === 3 && moves[2].x === 7 && moves[2].y === 6 && moves[2].player === BLACK && moves[2].label === "黑",
+ "move 3 = black (7,6)"
+ );
+
+ const b0 = boardAtStep(game, 0);
+ assert(b0.every((c) => c === 0), "boardAtStep(0) is an empty board");
+ assert(b0.length === createBoard().length, "boardAtStep(0) has 225 cells");
+
+ const b1 = boardAtStep(game, 1);
+ const b1Stones = b1.reduce((n, c) => n + (c !== 0 ? 1 : 0), 0);
+ assert(b1Stones === 1 && b1[idx(7, 7)] === BLACK, "boardAtStep(1) contains only black (7,7)");
+
+ const b2 = boardAtStep(game, 2);
+ assert(
+ b2[idx(7, 7)] === BLACK && b2[idx(8, 8)] === WHITE && b2[idx(7, 6)] === 0,
+ "boardAtStep(2) has moves 1-2 but not move 3"
+ );
+
+ const b3 = boardAtStep(game, 3);
+ assert(b3 === game.board, "boardAtStep(N) is the final board");
+
+ // snapshot equivalence + freeze semantics
+ const snap = snapshotGame(game);
+ const snapMoves = deriveMoves(snap);
+ assert(
+ JSON.stringify(snapMoves) === JSON.stringify(moves),
+ "deriveMoves(snapshot) === deriveMoves(game)"
+ );
+ const afterSnap = engine.placeStone(game, { x: 9, y: 9 }, WHITE);
+ assert(afterSnap.moveCount === 4 && snap.moveCount === 3, "snapshot is immune to later live moves");
+}
+
+// ---------------------------------------------------------------------------
+// 2. buildMeta + toSGF exact string
+// ---------------------------------------------------------------------------
+{
+ const meta = buildMeta(game, BLACK);
+ assert(meta.blackName === "玩家" && meta.whiteName === "电脑", "meta: human black -> PB=玩家 PW=电脑");
+ assert(meta.result === "?", "meta: ongoing game -> RE ?");
+ assert(meta.boardSize === 15 && meta.moveCount === 3, "meta: boardSize 15, moveCount 3");
+ assert(meta.date === localDate(), "meta: local date YYYY-MM-DD");
+
+ const moves = deriveMoves(game);
+ const sgf = toSGF(moves, meta);
+ const expected = `(;GM[4]FF[4]CA[UTF-8]SZ[15]AP[Gomoku3D:1.0]DT[${localDate()}]PB[玩家]PW[电脑]RE[?];B[hh];W[ii];B[hg])`;
+ assert(sgf === expected, "toSGF exact match: " + sgf);
+
+ // reversed colors: human plays white
+ const metaW = buildMeta(game, WHITE);
+ assert(metaW.blackName === "电脑" && metaW.whiteName === "玩家", "meta: human white -> PB=电脑 PW=玩家");
+
+ // finished game result mapping
+ const asBlackWin = { ...game, status: "black_win" as const };
+ const asWhiteWin = { ...game, status: "white_win" as const };
+ const asDraw = { ...game, status: "draw" as const };
+ assert(buildMeta(asBlackWin, BLACK).result === "B+W", "RE: black_win -> B+W");
+ assert(buildMeta(asWhiteWin, BLACK).result === "W+W", "RE: white_win -> W+W");
+ assert(buildMeta(asDraw, BLACK).result === "0", "RE: draw -> 0");
+ assert(resultToChinese("B+W") === "黑胜" && resultToChinese("?") === "未结束", "result Chinese mapping");
+}
+
+// ---------------------------------------------------------------------------
+// 3. toJSON / toTXT
+// ---------------------------------------------------------------------------
+{
+ const moves = deriveMoves(game);
+ const meta = buildMeta(game, BLACK);
+
+ const parsed = JSON.parse(toJSON(moves, meta)) as {
+ meta: { boardSize: number; blackName: string; result: string; moveCount: number };
+ moves: { index: number; x: number; y: number; player: number; label: string }[];
+ };
+ assert(parsed.moves.length === 3, "toJSON: parses back with 3 moves");
+ assert(parsed.moves[0].index === 1 && parsed.moves[0].player === 1, "toJSON: index starts at 1, player 1=black");
+ assert(parsed.meta.boardSize === 15 && parsed.meta.blackName === "玩家", "toJSON: meta fields present");
+
+ const txt = toTXT(moves, meta);
+ const lines = txt.trimEnd().split("\n");
+ assert(lines.length === 4, "toTXT: header + 3 move lines");
+ assert(lines[0].includes("五子棋棋谱") && lines[0].includes("结果:未结束"), "toTXT: header line");
+ assert(lines[1] === "1. 黑 (7,7)" && lines[2] === "2. 白 (8,8)" && lines[3] === "3. 黑 (7,6)", "toTXT: move lines format");
+}
+
+// ---------------------------------------------------------------------------
+// 4. sanitizeFileName / defaultFileName
+// ---------------------------------------------------------------------------
+{
+ assert(sanitizeFileName('a/b:c*') === "a-b-c-", 'sanitizeFileName("a/b:c*") === "a-b-c-"');
+ assert(sanitizeFileName('x?"<>|\\y') === "x------y", "sanitizeFileName strips all illegal chars");
+ assert(sanitizeFileName("") === "", "sanitizeFileName keeps empty string empty");
+
+ const name = defaultFileName();
+ assert(name.startsWith("gomoku-"), "defaultFileName starts with gomoku-");
+ assert(/\d{8}-\d{6}$/.test(name), "defaultFileName ends with YYYYMMDD-HHMMSS");
+}
+
+// ---------------------------------------------------------------------------
+// 5. edge: single-move game and empty game
+// ---------------------------------------------------------------------------
+{
+ let g1 = engine.createGame();
+ g1 = engine.placeStone(g1, { x: 0, y: 14 }, BLACK);
+ const m = deriveMoves(g1);
+ assert(m.length === 1 && m[0].x === 0 && m[0].y === 14, "single move derived at corner (0,14)");
+ const meta = buildMeta(g1, BLACK);
+ const sgf = toSGF(m, meta);
+ assert(sgf.includes(";B[ao])"), "SGF coord (0,14) -> ao (x first, then y)");
+
+ const g0 = engine.createGame();
+ assert(deriveMoves(g0).length === 0, "empty game derives zero moves");
+ assert(boardAtStep(g0, 0).every((c) => c === 0), "empty game boardAtStep(0) empty");
+}
+
+console.log(`\n${pass} passed, ${fail} failed`);
+if (fail > 0) process.exit(1);
diff --git a/plugins/gomoku-3d-ztools/scripts/sim.ts b/plugins/gomoku-3d-ztools/scripts/sim.ts
new file mode 100644
index 000000000..9cd744815
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/scripts/sim.ts
@@ -0,0 +1,55 @@
+import {
+ Board,
+ Move,
+ Player,
+ BLACK,
+ WHITE,
+ createBoard,
+ idx,
+ checkWinFrom,
+ isFull,
+} from "../src/lib/gomoku/types";
+import { getAIMove } from "../src/lib/gomoku/ai";
+
+function playGame(a: Player, b: Player, da: "easy" | "medium" | "hard" | "master", db: "easy" | "medium" | "hard" | "master") {
+ const board = createBoard();
+ let cur: Player = BLACK;
+ let moves = 0;
+ while (moves < 225) {
+ const diff = cur === a ? da : db;
+ const m = getAIMove(board, cur, diff);
+ if (!m) throw new Error(`getAIMove returned null at move ${moves} for player ${cur} (not a draw yet?)`);
+ if (board[idx(m.x, m.y)] !== 0) throw new Error(`AI returned an occupied cell ${m.x},${m.y}`);
+ board[idx(m.x, m.y)] = cur;
+ if (checkWinFrom(board, m.x, m.y, cur)) {
+ return { winner: cur, moves };
+ }
+ if (isFull(board)) return { winner: 0 as Player, moves };
+ cur = (cur === BLACK ? WHITE : BLACK) as Player;
+ moves++;
+ }
+ throw new Error("game did not terminate within 225 moves (possible stall/loop)");
+}
+
+const combos: [("easy" | "medium" | "hard" | "master"), ("easy" | "medium" | "hard" | "master")][] = [
+ ["easy", "easy"],
+ ["medium", "medium"],
+ ["hard", "hard"],
+ ["hard", "easy"],
+ ["easy", "hard"],
+ ["medium", "hard"],
+ // master tier — must terminate and play legally under the time budget
+ ["master", "easy"],
+ ["master", "medium"],
+ ["master", "hard"],
+ ["master", "master"],
+];
+let ok = 0;
+for (const [da, db] of combos) {
+ for (let i = 0; i < 5; i++) {
+ const r = playGame(BLACK, WHITE, da, db);
+ console.log(`AI(${da}) vs AI(${db}) #${i + 1}: winner=${r.winner} in ${r.moves} moves`);
+ ok++;
+ }
+}
+console.log(`\n${ok} full games completed with no stalls and no illegal moves.`);
diff --git a/plugins/gomoku-3d-ztools/scripts/strength.test.ts b/plugins/gomoku-3d-ztools/scripts/strength.test.ts
new file mode 100644
index 000000000..81bee1890
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/scripts/strength.test.ts
@@ -0,0 +1,149 @@
+import {
+ Board,
+ Move,
+ Player,
+ BLACK,
+ WHITE,
+ createBoard,
+ idx,
+ checkWinFrom,
+ isFull,
+ opponent,
+} from "../src/lib/gomoku/types";
+import { getAIMove, type Difficulty } from "../src/lib/gomoku/ai";
+
+/** Play one full game. blackDiff / whiteDiff select the tier for each color.
+ * Returns the winner (1=BLACK, 2=WHITE, 0=draw) and move count. */
+function playGame(
+ blackDiff: Difficulty,
+ whiteDiff: Difficulty
+): { winner: number; moves: number } {
+ const board: Board = createBoard();
+ let cur: Player = BLACK;
+ let moves = 0;
+ while (moves < 225) {
+ const diff = cur === BLACK ? blackDiff : whiteDiff;
+ const m = getAIMove(board, cur, diff);
+ if (!m) return { winner: 0, moves }; // full board -> draw
+ if (board[idx(m.x, m.y)] !== 0) throw new Error(`illegal move ${m.x},${m.y}`);
+ board[idx(m.x, m.y)] = cur;
+ if (checkWinFrom(board, m.x, m.y, cur)) return { winner: cur, moves };
+ if (isFull(board)) return { winner: 0, moves };
+ cur = opponent(cur);
+ moves++;
+ }
+ return { winner: 0, moves };
+}
+
+const winnerLabel = (w: number): string =>
+ w === 1 ? "BLACK" : w === 2 ? "WHITE" : "DRAW";
+
+const MASTER: Difficulty = "master";
+const others: Difficulty[] = ["easy", "medium", "hard"];
+const EASY_RUNS = 9; // easy is stochastic; average over several games
+
+interface Result {
+ winner: number;
+ moves: number;
+ masterIsBlack: boolean;
+ other: Difficulty;
+ w: number; // master wins
+ d: number; // draws
+ l: number; // master losses
+}
+
+const results: Record = {};
+
+let pass = 0;
+let fail = 0;
+function check(cond: boolean, msg: string) {
+ if (cond) {
+ pass++;
+ console.log(" ok -", msg);
+ } else {
+ fail++;
+ console.log("FAIL -", msg);
+ }
+}
+
+console.log("=== Strength matrix: master vs tier, both colors ===");
+for (const other of others) {
+ for (const masterIsBlack of [true, false]) {
+ const blackDiff: Difficulty = masterIsBlack ? MASTER : other;
+ const whiteDiff: Difficulty = masterIsBlack ? other : MASTER;
+ const tag = `master(${masterIsBlack ? "B" : "W"}) vs ${other}(${masterIsBlack ? "W" : "B"})`;
+
+ const runs = other === "easy" ? EASY_RUNS : 1;
+ let aggW = 0;
+ let aggD = 0;
+ let aggL = 0;
+ let lastWinner = 0;
+ let lastMoves = 0;
+ for (let i = 0; i < runs; i++) {
+ const r = playGame(blackDiff, whiteDiff);
+ lastWinner = r.winner;
+ lastMoves = r.moves;
+ const masterWon = r.winner !== 0 && r.winner === (masterIsBlack ? BLACK : WHITE);
+ if (r.winner === 0) aggD++;
+ else if (masterWon) aggW++;
+ else aggL++;
+ }
+
+ results[tag] = {
+ winner: lastWinner,
+ moves: lastMoves,
+ masterIsBlack,
+ other,
+ w: aggW,
+ d: aggD,
+ l: aggL,
+ };
+
+ const detail = other === "easy" ? `(easy x${EASY_RUNS}: W${aggW}/D${aggD}/L${aggL})` : "";
+ console.log(
+ ` ${tag.padEnd(34)} -> ${winnerLabel(lastWinner).padEnd(6)} ${detail}`
+ );
+ }
+}
+
+console.log("\n=== Acceptance criteria ===");
+
+// 1. master must NOT lose to easy/medium in ANY color direction.
+for (const other of ["easy", "medium"] as Difficulty[]) {
+ for (const dir of [true, false] as const) {
+ const tag = `master(${dir ? "B" : "W"}) vs ${other}(${dir ? "W" : "B"})`;
+ const r = results[tag];
+ check(r.l === 0, `${tag}: master never loses to ${other} (W${r.w}/D${r.d}/L${r.l})`);
+ }
+}
+
+// 2. master vs hard: never lose in either direction, and win at least one.
+{
+ const bTag = `master(B) vs hard(W)`;
+ const wTag = `master(W) vs hard(B)`;
+ const rb = results[bTag];
+ const rw = results[wTag];
+ check(rb.l === 0 && rw.l === 0, "master never loses to hard in either direction");
+ const wonOne = rb.w > 0 || rw.w > 0;
+ check(
+ wonOne,
+ `master beats hard in at least one color direction (B:W${rb.w}/D${rb.d}/L${rb.l}, W:W${rw.w}/D${rw.d}/L${rw.l})`
+ );
+}
+
+// 3. master single-move time budget.
+{
+ const mid = createBoard();
+ const seeds: [number, number, Player][] = [
+ [7, 7, BLACK], [7, 8, WHITE], [8, 7, BLACK], [6, 8, WHITE],
+ [8, 8, BLACK], [6, 7, WHITE], [9, 7, BLACK], [9, 8, WHITE], [5, 7, BLACK],
+ ];
+ for (const [x, y, p] of seeds) mid[idx(x, y)] = p;
+ const t0 = performance.now();
+ const m = getAIMove(mid, WHITE, "master");
+ const dt = performance.now() - t0;
+ check(!!m && dt < 1000, `master single move < 1s (took ${dt.toFixed(1)}ms)`);
+}
+
+console.log(`\n${pass} passed, ${fail} failed`);
+if (fail > 0) process.exit(1);
diff --git a/plugins/gomoku-3d-ztools/src-ztools/LICENSE b/plugins/gomoku-3d-ztools/src-ztools/LICENSE
new file mode 100644
index 000000000..88fd429ea
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src-ztools/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 crper
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/plugins/gomoku-3d-ztools/src-ztools/NOTICE b/plugins/gomoku-3d-ztools/src-ztools/NOTICE
new file mode 100644
index 000000000..68f9dc7bf
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src-ztools/NOTICE
@@ -0,0 +1,6 @@
+Gomoku 3D for ZTools
+
+Ported from https://github.com/crper/gomoku-3d at commit
+fa6e266e4e9135aebb80e1487de88a6438380cf7.
+
+Upstream copyright (c) 2026 crper, licensed under the MIT License.
diff --git a/plugins/gomoku-3d-ztools/src-ztools/logo.png b/plugins/gomoku-3d-ztools/src-ztools/logo.png
new file mode 100644
index 000000000..37736232e
Binary files /dev/null and b/plugins/gomoku-3d-ztools/src-ztools/logo.png differ
diff --git a/plugins/gomoku-3d-ztools/src-ztools/logo.svg b/plugins/gomoku-3d-ztools/src-ztools/logo.svg
new file mode 100644
index 000000000..8044ce681
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src-ztools/logo.svg
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/gomoku-3d-ztools/src-ztools/plugin.json b/plugins/gomoku-3d-ztools/src-ztools/plugin.json
new file mode 100644
index 000000000..338efed40
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src-ztools/plugin.json
@@ -0,0 +1,26 @@
+{
+ "name": "gomoku-3d-ztools",
+ "title": "五子棋 3D",
+ "description": "支持四档 AI、2D/3D 视图、悔棋、回放和棋谱导出的五子棋游戏。",
+ "author": "crper, ZTools port contributors",
+ "version": "1.0.2",
+ "main": "dist/index.html",
+ "preload": "preload/services.js",
+ "logo": "logo.png",
+ "development": {
+ "main": "http://127.0.0.1:5173/"
+ },
+ "features": [
+ {
+ "code": "gomoku-3d",
+ "explain": "打开五子棋 3D,与四档本地 AI 对弈",
+ "icon": "logo.png",
+ "cmds": [
+ "五子棋",
+ "五子棋 3D",
+ "Gomoku",
+ "连珠"
+ ]
+ }
+ ]
+}
diff --git a/plugins/gomoku-3d-ztools/src-ztools/preload/package.json b/plugins/gomoku-3d-ztools/src-ztools/preload/package.json
new file mode 100644
index 000000000..5bbefffba
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src-ztools/preload/package.json
@@ -0,0 +1,3 @@
+{
+ "type": "commonjs"
+}
diff --git a/plugins/gomoku-3d-ztools/src-ztools/preload/services.js b/plugins/gomoku-3d-ztools/src-ztools/preload/services.js
new file mode 100644
index 000000000..38ced92a9
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src-ztools/preload/services.js
@@ -0,0 +1,61 @@
+const fs = require('node:fs')
+const path = require('node:path')
+
+const FORMAT_CONFIG = Object.freeze({
+ sgf: { extension: '.sgf', name: 'SGF 棋谱' },
+ json: { extension: '.json', name: 'JSON 棋谱' },
+ txt: { extension: '.txt', name: '文本棋谱' },
+})
+
+/**
+ * 校验并规范化页面传入的棋谱保存参数。
+ * @param {unknown} input 页面提交的参数。
+ * @returns {{format: 'sgf' | 'json' | 'txt', suggestedName: string, text: string}} 安全的保存参数。
+ * @throws 参数类型、文件名或内容不合法时抛出错误。
+ */
+function normalizeSaveInput(input) {
+ if (!input || typeof input !== 'object') throw new Error('棋谱保存参数无效')
+ const { format, suggestedName, text } = input
+ if (!Object.prototype.hasOwnProperty.call(FORMAT_CONFIG, format)) {
+ throw new Error('不支持的棋谱格式')
+ }
+ if (typeof suggestedName !== 'string' || !suggestedName.trim()) {
+ throw new Error('请输入有效的文件名')
+ }
+ if (typeof text !== 'string' || text.length === 0 || text.length > 2 * 1024 * 1024) {
+ throw new Error('棋谱内容为空或超过 2 MB')
+ }
+
+ const cleanName = path.basename(suggestedName.trim()).replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-')
+ if (!cleanName) throw new Error('请输入有效的文件名')
+ return { format, suggestedName: cleanName.slice(0, 160), text }
+}
+
+/**
+ * 使用宿主保存对话框选择目标文件,并写入 UTF-8 棋谱。
+ * @param {unknown} input 页面提交的保存参数。
+ * @returns {{cancelled: boolean, path?: string}} 保存结果。
+ * @throws 宿主能力不可用或写入失败时抛出错误。
+ */
+function saveTextFile(input) {
+ const normalized = normalizeSaveInput(input)
+ const config = FORMAT_CONFIG[normalized.format]
+ const ztools = globalThis.ztools || globalThis.window?.ztools
+ if (!ztools || typeof ztools.showSaveDialog !== 'function') {
+ throw new Error('当前环境不支持系统保存对话框')
+ }
+
+ let selectedPath = ztools.showSaveDialog({
+ title: '导出五子棋棋谱',
+ defaultPath: normalized.suggestedName,
+ filters: [{ name: config.name, extensions: [config.extension.slice(1)] }],
+ })
+ if (!selectedPath) return { cancelled: true }
+ if (path.extname(selectedPath).toLowerCase() !== config.extension) {
+ selectedPath += config.extension
+ }
+ fs.writeFileSync(selectedPath, normalized.text, 'utf8')
+ return { cancelled: false, path: selectedPath }
+}
+
+window.gomokuBridge = Object.freeze({ saveTextFile })
diff --git a/plugins/gomoku-3d-ztools/src/App.tsx b/plugins/gomoku-3d-ztools/src/App.tsx
new file mode 100644
index 000000000..20dad6fe5
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/App.tsx
@@ -0,0 +1,1044 @@
+import * as React from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ RotateCcw,
+ Circle,
+ Bot,
+ User,
+ Trophy,
+ Volume2,
+ VolumeX,
+ Undo2,
+ Dices,
+ Shield,
+ Sword,
+ Crown,
+ Sparkles,
+ Box,
+ Grid3x3,
+ Film,
+ Download,
+ Home,
+ type LucideIcon,
+} from "lucide-react";
+
+import GameBoard from "@/components/GameBoard";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+} from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Separator } from "@/components/ui/separator";
+import { Switch } from "@/components/ui/switch";
+import { Label } from "@/components/ui/label";
+import { cn } from "@/lib/utils";
+import { Toaster } from "@/components/ui/toast";
+import { LanguageSwitcher } from "@/components/LanguageSwitcher";
+import { useGomoku } from "@/hooks/useGomoku";
+import { useViewMode, type ViewMode } from "@/hooks/useViewMode";
+import { useReplay } from "@/hooks/useReplay";
+import ReplayPanel from "@/components/ReplayPanel";
+import ExportDialog from "@/components/ExportDialog";
+import { play } from "@/lib/audio/sfx";
+import {
+ ACHIEVEMENTS,
+ isEarned,
+ TONE_CLASSES,
+} from "@/lib/gomoku/stats";
+import { ACH_ICON } from "@/lib/achievementIcons";
+import { type Difficulty } from "@/lib/gomoku/ai";
+import { BLACK, WHITE } from "@/lib/gomoku/types";
+
+/** Tween an integer from its previous value to the new one. */
+function AnimatedNumber({ value }: { value: number }) {
+ const [disp, setDisp] = useState(value);
+ const fromRef = useRef(value);
+ useEffect(() => {
+ const from = fromRef.current;
+ if (from === value) return;
+ const start = performance.now();
+ const dur = 500;
+ let raf = 0;
+ const tick = (t: number) => {
+ const p = Math.min(1, (t - start) / dur);
+ const e = 1 - Math.pow(1 - p, 3);
+ setDisp(Math.round(from + (value - from) * e));
+ if (p < 1) raf = requestAnimationFrame(tick);
+ else fromRef.current = value;
+ };
+ raf = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(raf);
+ }, [value]);
+ return {disp} ;
+}
+
+/** One-shot celebratory burst, rendered when the title is clicked or the player wins. */
+function Confetti() {
+ const pieces = useMemo(
+ () =>
+ Array.from({ length: 26 }, () => {
+ const angle = Math.random() * Math.PI * 2;
+ const dist = 90 + Math.random() * 180;
+ const palette = [
+ "#f97316",
+ "#f59e0b",
+ "#ef4444",
+ "#8b5cf6",
+ "#10b981",
+ "#0ea5e9",
+ ];
+ return {
+ left: 50 + (Math.random() * 22 - 11),
+ cx: Math.cos(angle) * dist,
+ cy: Math.sin(angle) * dist - 30,
+ color: palette[Math.floor(Math.random() * palette.length)],
+ delay: Math.random() * 0.18,
+ w: 6 + Math.random() * 6,
+ h: 4 + Math.random() * 5,
+ };
+ }),
+ []
+ );
+ return (
+
+ {pieces.map((p, i) => (
+
+ ))}
+
+ );
+}
+
+type GameResult = "win" | "lose" | "draw";
+
+/**
+ * Semi-transparent game-over overlay with play-again / replay / dismiss actions.
+ * Mounts on game end and animates in; on dismiss it animates out before
+ * unmounting. All motion collapses under `prefers-reduced-motion`.
+ */
+function GameOverOverlay({
+ open,
+ result,
+ canReplay,
+ onPlayAgain,
+ onReplay,
+ onDismiss,
+}: {
+ open: boolean;
+ result: GameResult;
+ canReplay: boolean;
+ onPlayAgain: () => void;
+ onReplay: () => void;
+ onDismiss: () => void;
+}) {
+ const { t } = useTranslation();
+ const [render, setRender] = useState(open);
+ const [show, setShow] = useState(false);
+
+ useEffect(() => {
+ if (open) {
+ setRender(true);
+ const r = requestAnimationFrame(() => setShow(true));
+ return () => cancelAnimationFrame(r);
+ }
+ setShow(false);
+ const tm = window.setTimeout(() => setRender(false), 260);
+ return () => window.clearTimeout(tm);
+ }, [open]);
+
+ if (!render) return null;
+
+ const theme = {
+ win: {
+ Icon: Trophy,
+ chip: "bg-amber-100 text-amber-600",
+ title: t("overlay.win"),
+ desc: t("overlay.winDesc"),
+ },
+ lose: {
+ Icon: Sword,
+ chip: "bg-rose-100 text-rose-600",
+ title: t("overlay.lose"),
+ desc: t("overlay.loseDesc"),
+ },
+ draw: {
+ Icon: Circle,
+ chip: "bg-sky-100 text-sky-600",
+ title: t("overlay.draw"),
+ desc: t("overlay.drawDesc"),
+ },
+ }[result];
+ const Icon = theme.Icon;
+
+ return (
+
+
+
+
+
+
+
+
+ {theme.title}
+
+
{theme.desc}
+
+
+
+ {t("overlay.playAgain")}
+
+
+
+ {t("overlay.replay")}
+
+
+ {t("overlay.backToMenu")}
+
+
+
+
+
+ );
+}
+
+const DIFFICULTIES: { id: Difficulty; icon: LucideIcon }[] = [
+ { id: "easy", icon: Dices },
+ { id: "medium", icon: Shield },
+ { id: "hard", icon: Sword },
+ { id: "master", icon: Crown },
+];
+
+function diffSelectedClass(id: Difficulty): string {
+ switch (id) {
+ case "easy":
+ return "border-emerald-400 bg-emerald-50 text-emerald-700 shadow-[var(--shadow-glow)]";
+ case "medium":
+ return "border-sky-400 bg-sky-50 text-sky-700 shadow-[var(--shadow-glow)]";
+ case "hard":
+ return "border-orange-400 bg-orange-50 text-orange-700 shadow-[var(--shadow-glow)]";
+ case "master":
+ return "border-rose-400 bg-rose-50 text-rose-700 shadow-[var(--shadow-glow)] ring-2 ring-rose-200";
+ }
+}
+
+export default function App() {
+ const { t, i18n } = useTranslation();
+ const {
+ game,
+ board,
+ currentPlayer,
+ status,
+ lastMove,
+ winLine,
+ winner,
+ moveCount,
+ difficulty,
+ humanColor,
+ thinking,
+ stats,
+ muted,
+ mmss,
+ toasts,
+ interactive,
+ canUndo,
+ setDifficulty,
+ setHumanColor,
+ setHover,
+ handlePlace,
+ restart,
+ undo,
+ toggleMute,
+ pushToast,
+ dismissToast,
+ } = useGomoku();
+
+ const [par, setPar] = useState({ x: 0, y: 0 });
+ const [egg, setEgg] = useState(false);
+ const [boardMounted, setBoardMounted] = useState(false);
+ const [viewMode, setViewMode] = useViewMode();
+ const [exportOpen, setExportOpen] = useState(false);
+ const [overlayClosed, setOverlayClosed] = useState(false);
+
+ // ---- replay (read-only view over a frozen snapshot; live game untouched) ----
+ const replay = useReplay(game, thinking);
+ const isReplay = replay.mode !== "idle";
+ const displayBoard = isReplay ? replay.display.board : board;
+ const displayLast = isReplay ? replay.display.lastMove : lastMove;
+ const displayWin = isReplay ? replay.display.winLine : winLine;
+ const displayInteractive = isReplay ? false : interactive;
+
+ const switchView = (m: ViewMode) => {
+ if (m === viewMode) return;
+ setViewMode(m);
+ play("ui_click");
+ };
+
+ // ---- keep / in sync with the active language ----
+ useEffect(() => {
+ document.title = t("header.documentTitle");
+ document.documentElement.lang = i18n.resolvedLanguage ?? i18n.language ?? "zh";
+ }, [t, i18n.resolvedLanguage, i18n.language]);
+
+ // ---- board mount animation ----
+ useEffect(() => {
+ const r = requestAnimationFrame(() => setBoardMounted(true));
+ return () => cancelAnimationFrame(r);
+ }, []);
+
+ // ---- reset the game-over overlay whenever a fresh game starts ----
+ useEffect(() => {
+ if (status === "playing") setOverlayClosed(false);
+ }, [status]);
+
+ // ---- parallax (no-op on touch / reduced motion) ----
+ useEffect(() => {
+ const onMove = (e: MouseEvent) => {
+ const x = e.clientX / window.innerWidth - 0.5;
+ const y = e.clientY / window.innerHeight - 0.5;
+ setPar({ x, y });
+ };
+ window.addEventListener("mousemove", onMove);
+ return () => window.removeEventListener("mousemove", onMove);
+ }, []);
+
+ // ---- replay keyboard shortcuts (capture phase so we can also shield the
+ // global Ctrl/Cmd+Z undo while the read-only replay is open) ----
+ const {
+ stepBack: rStepBack,
+ stepForward: rStepForward,
+ togglePlay: rTogglePlay,
+ exitReplay: rExit,
+ goTo: rGoTo,
+ N: rN,
+ } = replay;
+ useEffect(() => {
+ if (!isReplay) return;
+ const onKey = (e: KeyboardEvent) => {
+ const t = e.target as HTMLElement | null;
+ if (
+ t &&
+ (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)
+ )
+ return; // never hijack typing (e.g. the export filename input)
+ if (exportOpen) return; // dialog owns the keyboard while open
+ if ((e.ctrlKey || e.metaKey) && (e.key === "z" || e.key === "Z")) {
+ // replay is read-only: block the global undo shortcut
+ e.preventDefault();
+ e.stopPropagation();
+ return;
+ }
+ switch (e.key) {
+ case "ArrowLeft":
+ e.preventDefault();
+ rStepBack();
+ break;
+ case "ArrowRight":
+ e.preventDefault();
+ rStepForward();
+ break;
+ case " ":
+ e.preventDefault(); // keep the page from scrolling
+ rTogglePlay();
+ break;
+ case "Escape":
+ e.preventDefault();
+ rExit();
+ break;
+ case "Home":
+ e.preventDefault();
+ rGoTo(0);
+ break;
+ case "End":
+ e.preventDefault();
+ rGoTo(rN);
+ break;
+ }
+ };
+ window.addEventListener("keydown", onKey, true);
+ return () => window.removeEventListener("keydown", onKey, true);
+ }, [isReplay, exportOpen, rStepBack, rStepForward, rTogglePlay, rExit, rGoTo, rN]);
+
+ const celebrate =
+ (status === "black_win" || status === "white_win") && winner === humanColor;
+
+ const triggerEgg = () => {
+ setEgg(true);
+ window.setTimeout(() => setEgg(false), 1300);
+ pushToast({
+ title: t("toast.eggTitle"),
+ desc: t("toast.eggDesc"),
+ icon: ,
+ toneClass: "bg-amber-100 text-amber-600",
+ });
+ };
+
+ const statusText = useMemo(() => {
+ if (status === "draw")
+ return { label: t("status.draw"), tone: "secondary" as const };
+ if (status === "black_win" || status === "white_win")
+ return {
+ label: winner === humanColor ? t("status.youWin") : t("status.aiWin"),
+ tone: "default" as const,
+ };
+ if (thinking) return { label: t("status.thinking"), tone: "secondary" as const };
+ return {
+ label: currentPlayer === humanColor ? t("status.yourTurn") : t("status.aiTurn"),
+ tone: "secondary" as const,
+ };
+ }, [status, winner, thinking, currentPlayer, humanColor, t]);
+
+ const gameResult: GameResult | null =
+ status === "draw"
+ ? "draw"
+ : status === "black_win" || status === "white_win"
+ ? winner === humanColor
+ ? "win"
+ : "lose"
+ : null;
+ const showOverlay = !isReplay && gameResult !== null && !overlayClosed;
+
+ const streakPct = Math.min(stats.streak, 5) / 5;
+
+ return (
+
+ {/* ---- aurora background with mouse parallax ---- */}
+
+
+ {/* bottom padding = mobile action bar height (~61px: py-2.5×2 + 40px
+ buttons + border) + breathing room, plus the iOS safe-area inset */}
+
+ {/* ---- header ---- */}
+
+
+
+ {/* ---- board ---- */}
+ {/* Keep the board prominent in the embedded ZTools view while leaving
+ enough room for the header and fixed mobile action bar. */}
+
+
+
+
+ {/* AI thinking overlay: shimmer sweep + scanning + dots */}
+ {thinking && (
+
+
+
+
+
+ {t("board.thinking")}
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+
+ )}
+
+ {/* replay-mode badge + current move info (aria-live) */}
+ {isReplay && (
+
+
+
+ {t("board.replayMode")}
+
+ {replay.currentStep} / {replay.N}
+
+
+
+ )}
+
+ {/* game-over overlay (hidden while replaying mid-game states) */}
+ {gameResult && (
+
0 && !thinking}
+ onPlayAgain={restart}
+ onReplay={() => {
+ setOverlayClosed(true);
+ replay.enterReplay();
+ }}
+ onDismiss={() => setOverlayClosed(true)}
+ />
+ )}
+
+
+ {/* ---- board status bar ---- */}
+
+
+
+
+ {isReplay
+ ? t("board.replayReadonly")
+ : interactive
+ ? t("board.yourTurn")
+ : thinking
+ ? t("board.thinking")
+ : status === "playing"
+ ? t("board.aiTurn")
+ : ""}
+
+
+
+
+ {t("board.moves")}{" "}
+ {moveCount}
+
+
+ {t("board.time")}{" "}
+ {mmss}
+
+
+ ?
+
+
+
+
+
+ {/* ---- side panel ---- */}
+
+
+
+
+ {/* ---- mobile bottom action bar ---- */}
+
+ setHumanColor(humanColor === BLACK ? WHITE : BLACK)}
+ >
+
+
+
+
+
+
+
+
+
+ {muted ? : }
+
+ switchView(viewMode === "3d" ? "2d" : "3d")}
+ >
+ {viewMode === "2d" ? : }
+
+
+
+
+ setExportOpen(true)}
+ >
+
+
+
+
+ {/* ---- mobile replay bottom sheet (covers the action bar while open) ---- */}
+ {isReplay && (
+
+ setExportOpen(true)}
+ exportDisabled={moveCount === 0}
+ />
+
+ )}
+
+
setExportOpen(false)}
+ game={game}
+ humanColor={humanColor}
+ pushToast={pushToast}
+ />
+
+
+ {egg && }
+ {celebrate && }
+
+ );
+}
diff --git a/plugins/gomoku-3d-ztools/src/components/ExportDialog.tsx b/plugins/gomoku-3d-ztools/src/components/ExportDialog.tsx
new file mode 100644
index 000000000..db5318c3f
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/components/ExportDialog.tsx
@@ -0,0 +1,206 @@
+import { useEffect, useRef, useState } from "react";
+import { Download, X } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import type { GameState } from "@/lib/gomoku/engine";
+import type { Player } from "@/lib/gomoku/types";
+import { deriveMoves } from "@/lib/gomoku/replay";
+import {
+ buildMeta,
+ defaultFileName,
+ exportGame,
+ sanitizeFileName,
+ type ExportFormat,
+} from "@/lib/gomoku/export";
+import type { ToastItem } from "@/components/ui/toast";
+
+const FORMATS: { id: ExportFormat; name: string }[] = [
+ { id: "sgf", name: "SGF" },
+ { id: "json", name: "JSON" },
+ { id: "txt", name: "TXT" },
+];
+
+const EXT: Record = {
+ sgf: ".sgf",
+ json: ".json",
+ txt: ".txt",
+};
+
+export interface ExportDialogProps {
+ open: boolean;
+ onClose: () => void;
+ game: GameState;
+ humanColor: Player;
+ pushToast: (t: Omit) => void;
+}
+
+export default function ExportDialog({
+ open,
+ onClose,
+ game,
+ humanColor,
+ pushToast,
+}: ExportDialogProps) {
+ const [format, setFormat] = useState("sgf");
+ const [name, setName] = useState("");
+ const inputRef = useRef(null);
+ const { t } = useTranslation();
+
+ // reset + focus on every open
+ useEffect(() => {
+ if (!open) return;
+ setFormat("sgf");
+ setName(defaultFileName());
+ const t = window.setTimeout(() => inputRef.current?.focus(), 0);
+ return () => window.clearTimeout(t);
+ }, [open]);
+
+ // Esc closes the dialog only (capture + stopPropagation so the replay
+ // Esc handler does not also exit replay underneath)
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ e.stopPropagation();
+ onClose();
+ }
+ };
+ window.addEventListener("keydown", onKey, true);
+ return () => window.removeEventListener("keydown", onKey, true);
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ const canExport = game.moveCount > 0 && name.trim().length > 0;
+
+ const doExport = async () => {
+ if (!canExport) return;
+ try {
+ const moves = deriveMoves(game);
+ const meta = buildMeta(game, humanColor);
+ const { cancelled, filename, path } = await exportGame(moves, meta, format, name);
+ if (cancelled) return;
+ pushToast({
+ title: t("export.exported", { filename: path ?? filename }),
+ desc: t("export.exportedDesc", {
+ count: game.moveCount,
+ format: FORMATS.find((f) => f.id === format)!.name,
+ }),
+ icon: ,
+ toneClass: "bg-emerald-50 text-emerald-700",
+ });
+ onClose();
+ } catch {
+ pushToast({
+ title: t("export.exportFailed"),
+ toneClass: "bg-rose-50 text-rose-600",
+ });
+ }
+ };
+
+ return (
+
+
+
+
+
+ {t("export.title")}
+
+
+
+
+
+
+ {/* format */}
+
+
+ {t("export.format")}
+
+
+ {FORMATS.map((f) => (
+
+ setFormat(f.id)}
+ />
+
+ {f.name}
+
+ {t(`export.${f.id}.desc`)}
+
+
+
+ ))}
+
+
+
+ {/* filename */}
+
+
+ {t("export.filename")}
+
+
+ setName(sanitizeFileName(e.target.value))}
+ className="min-w-0 flex-1 bg-transparent text-sm text-stone-800 outline-none"
+ aria-label={t("export.filenameLabel")}
+ />
+
+ {EXT[format]}
+
+
+
+
+
+
+ {t("export.cancel")}
+
+
+ {t("export.export")}
+
+
+
+
+ );
+}
diff --git a/plugins/gomoku-3d-ztools/src/components/GameBoard.tsx b/plugins/gomoku-3d-ztools/src/components/GameBoard.tsx
new file mode 100644
index 000000000..b24748aa1
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/components/GameBoard.tsx
@@ -0,0 +1,1058 @@
+import * as THREE from "three";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Canvas, ThreeEvent, useFrame, useThree } from "@react-three/fiber";
+import { OrbitControls, RoundedBox, ContactShadows } from "@react-three/drei";
+import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
+import { Board, Move, Player, BLACK, idx } from "@/lib/gomoku/types";
+import type { ViewMode } from "@/hooks/useViewMode";
+
+const SPACING = 1;
+const HALF = (15 - 1) / 2; // 7
+const PLANE = 16;
+const STONE_RADIUS = 0.42;
+const STONE_FLAT = 0.45;
+/** Resting centre height of a placed stone (fx-spec §0.4). */
+const REST_Y = STONE_RADIUS * STONE_FLAT; // 0.189
+
+const BLACK_COLOR = "#16161a";
+const WHITE_COLOR = "#f4f4f5";
+const BOARD_COLOR = "#e9cfa3";
+const GUIDE_COLOR = "#f97316";
+/** fx-spec §0.3: wood-toned dust ripple, darker than the board. */
+const DUST_COLOR = "#b78a54";
+const STAR_POINTS: ReadonlyArray<[number, number]> = [
+ [3, 3],
+ [3, 11],
+ [11, 3],
+ [11, 11],
+ [7, 7],
+];
+
+/** fx-spec §5.3: victory / draw dim targets, pre-parsed once. */
+const C_BASE_BLACK = new THREE.Color(BLACK_COLOR);
+const C_BASE_WHITE = new THREE.Color(WHITE_COLOR);
+const C_DIM_BLACK = new THREE.Color("#0b0b0d");
+const C_DIM_WHITE = new THREE.Color("#a8a29e");
+
+const TAU = Math.PI * 2;
+
+// ---------------------------------------------------------------------------
+// fx-spec §0.1 easing library (module-level pure functions)
+// ---------------------------------------------------------------------------
+
+const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
+const easeOutQuad = (t: number) => 1 - (1 - t) * (1 - t);
+/** Overshoot rebound; larger c1 = higher overshoot. */
+const easeOutBack = (t: number, c1: number) => {
+ const c3 = c1 + 1;
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
+};
+
+function easeInOutCubic(t: number): number {
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
+}
+
+// ---------------------------------------------------------------------------
+// fx-spec §0.2: single reduced-motion entry point for the whole scene.
+// Module-level constant — every effect below reads this one flag.
+// ---------------------------------------------------------------------------
+
+const REDUCED =
+ typeof window !== "undefined" &&
+ !!window.matchMedia &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+
+function gridToWorld(x: number, y: number): [number, number, number] {
+ return [(x - HALF) * SPACING, 0, (y - HALF) * SPACING];
+}
+
+function worldToGrid(p: THREE.Vector3): Move | null {
+ const gx = Math.round(p.x / SPACING + HALF);
+ const gy = Math.round(p.z / SPACING + HALF);
+ if (gx < 0 || gx > 14 || gy < 0 || gy > 14) return null;
+ const [wx, , wz] = gridToWorld(gx, gy);
+ if (Math.hypot(p.x - wx, p.z - wz) > 0.5) return null;
+ return { x: gx, y: gy };
+}
+
+function stoneColor(player: Player): string {
+ return player === BLACK ? BLACK_COLOR : WHITE_COLOR;
+}
+
+/**
+ * fx-spec §1.3 touch-down squash, applied to the Y scale only (3D view).
+ * 120–170ms: 1.0 → 0.84; 170ms → D: 0.84 → 1.0; flat 1.0 elsewhere.
+ */
+function squashFactor(tSec: number, dSec: number): number {
+ const tms = tSec * 1000;
+ if (tms < 120) return 1;
+ if (tms < 170) return 1 - 0.16 * easeOutQuad((tms - 120) / 50);
+ const dms = dSec * 1000;
+ if (tms < dms) return 0.84 + 0.16 * easeOutQuad((tms - 170) / (dms - 170));
+ return 1;
+}
+
+/**
+ * A single stone.
+ *
+ * fx-spec §1 (drop / bounce entrance), §5.1 (win-line travelling shimmer),
+ * §5.2 (win-line hop, or XZ pulse in 2D) and §5.3 (dimming) all share this one
+ * `useFrame` callback. When nothing is animating the callback early-returns
+ * (§1.1 perf guard) so a full 225-stone board costs nothing per frame.
+ */
+function Stone({
+ x,
+ y,
+ player,
+ highlight,
+ entrance,
+ viewMode,
+ winIndex,
+ dimK,
+ dimMs,
+}: {
+ x: number;
+ y: number;
+ player: Player;
+ highlight?: boolean;
+ /** "bounce" = the freshly played stone; "pop" = bulk (re)mount. */
+ entrance: "bounce" | "pop";
+ viewMode: ViewMode;
+ /** 0–4 position along the winning line; undefined = not a winning stone. */
+ winIndex?: number;
+ /** Target dim amount: 0 none, 0.5 draw, 1 victory (non-winning stones). */
+ dimK: number;
+ dimMs: number;
+}) {
+ const ref = useRef(null);
+ const matRef = useRef(null);
+ const t = useRef(0);
+ const settled = useRef(false);
+ const dimP = useRef(0);
+ const igniteAt = useRef(null);
+ // Frozen at mount: a later lastMove change must not restart / swap the
+ // entrance animation of an already-placed stone.
+ const mode = useRef<"bounce" | "pop">(
+ entrance === "bounce" && !REDUCED ? "bounce" : "pop"
+ ).current;
+
+ const [wx, , wz] = gridToWorld(x, y);
+ const color = stoneColor(player);
+ const baseColor = player === BLACK ? C_BASE_BLACK : C_BASE_WHITE;
+ const dimColor = player === BLACK ? C_DIM_BLACK : C_DIM_WHITE;
+ // §1.2 black is heavier / slower, white lighter / snappier.
+ const dur = player === BLACK ? 0.28 : 0.23;
+ const c1 = player === BLACK ? 1.4 : 1.95;
+
+ useFrame(({ clock }, delta) => {
+ const mesh = ref.current;
+ if (!mesh) return;
+
+ const wi = winIndex;
+ const waveOn = wi !== undefined && !REDUCED;
+ const dimOn = dimK > 0 && dimP.current < 1;
+ const entranceOn = !settled.current;
+ // §1.1 performance guard: fully settled, non-winning, non-dimming stones
+ // do zero work per frame.
+ if (!waveOn && !dimOn && !entranceOn) return;
+
+ if (entranceOn) {
+ t.current += delta;
+ if (mode === "pop") {
+ // §1.5 / §8.3-1 degraded + bulk-mount path: 160ms easeOutCubic.
+ const p = Math.min(1, t.current / 0.16);
+ const e = easeOutCubic(p);
+ mesh.scale.set(e, e * STONE_FLAT, e);
+ mesh.position.y = REST_Y;
+ if (p >= 1) {
+ mesh.scale.set(1, STONE_FLAT, 1);
+ settled.current = true;
+ }
+ } else {
+ const p = Math.min(1, t.current / dur);
+ const s = easeOutBack(p, c1);
+ // §8.4-1: the Y drop and the touch-down squash are invisible from the
+ // locked top-down camera, so 2D runs the XZ overshoot only.
+ const is3d = viewMode === "3d";
+ const sq = is3d ? squashFactor(t.current, dur) : 1;
+ mesh.scale.set(s, s * STONE_FLAT * sq, s);
+ mesh.position.y = is3d
+ ? REST_Y + 0.55 * (1 - easeOutCubic(Math.min(1, (t.current * 1000) / 120)))
+ : REST_Y;
+ if (p >= 1) {
+ mesh.scale.set(1, STONE_FLAT, 1);
+ mesh.position.y = REST_Y;
+ settled.current = true;
+ }
+ }
+ }
+
+ if (dimOn) {
+ // §5.3 stock-based interpolation (never accumulate frame-rate dependent
+ // lerps). §8.3-5c: reduced motion applies the same end state instantly.
+ dimP.current = REDUCED
+ ? 1
+ : Math.min(1, dimP.current + (delta * 1000) / dimMs);
+ const m = matRef.current;
+ if (m) m.color.lerpColors(baseColor, dimColor, dimK * easeOutQuad(dimP.current));
+ }
+
+ if (waveOn && wi !== undefined) {
+ const tms = clock.elapsedTime * 1000;
+ if (igniteAt.current === null) igniteAt.current = tms;
+ // §5.1 one-shot ignition order: stone i only lights up after i*120ms.
+ const w =
+ tms - igniteAt.current >= wi * 120
+ ? Math.max(0, Math.sin((TAU * (tms - wi * 216)) / 1800))
+ : 0;
+ const m = matRef.current;
+ if (m) m.emissiveIntensity = 0.25 + 0.85 * w * w * w;
+ // Transform is only taken over once the entrance finished, so the two
+ // animations never fight over scale/position.
+ if (settled.current) {
+ if (viewMode === "3d") {
+ // §5.2 3D: hop.
+ mesh.position.y = REST_Y + 0.35 * w * w;
+ mesh.scale.set(1, STONE_FLAT, 1);
+ } else {
+ // §5.2 / §8.4-5b 2D substitute: in-phase XZ scale pulse to 112%.
+ const s = 1 + 0.12 * w * w;
+ mesh.position.y = REST_Y;
+ mesh.scale.set(s, STONE_FLAT, s);
+ }
+ }
+ }
+ });
+
+ const isWinStone = winIndex !== undefined;
+
+ return (
+
+
+
+
+ );
+}
+
+function GridLines() {
+ const lines = useMemo(() => {
+ const arr: { pos: [number, number, number]; size: [number, number, number] }[] =
+ [];
+ for (let g = 0; g < 15; g++) {
+ const w = (g - HALF) * SPACING;
+ // vertical
+ arr.push({ pos: [w, 0.012, 0], size: [0.025, 0.02, PLANE - 2] });
+ // horizontal
+ arr.push({ pos: [0, 0.012, w], size: [PLANE - 2, 0.02, 0.025] });
+ }
+ return arr;
+ }, []);
+
+ return (
+
+ {lines.map((l, i) => (
+
+
+
+
+ ))}
+ {STAR_POINTS.map(([x, y]) => {
+ const [wx, , wz] = gridToWorld(x, y);
+ return (
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * fx-spec §2: dust ripple kicked up at the landing point.
+ * Mounted keyed by move, self-unmounts once both rings finished so R3F
+ * disposes the JSX-declared materials automatically (no manual pooling).
+ */
+function ImpactRipple({ move }: { move: Move }) {
+ const r1 = useRef(null);
+ const r2 = useRef(null);
+ const t = useRef(0);
+ const doneRef = useRef(false);
+ const [done, setDone] = useState(false);
+ const [wx, , wz] = gridToWorld(move.x, move.y);
+ // Both rings share one geometry; only the materials animate independently.
+ const geo = useMemo(() => new THREE.RingGeometry(0.3, 0.4, 40), []);
+ useEffect(() => () => geo.dispose(), [geo]);
+
+ useFrame((_, delta) => {
+ if (doneRef.current) return;
+ t.current += delta;
+ const tms = t.current * 1000;
+
+ if (r1.current) {
+ const p = Math.min(1, tms / 450);
+ const s = 1 + easeOutCubic(p) * 1.2; // 1.0 → 2.2
+ r1.current.scale.set(s, s, 1);
+ (r1.current.material as THREE.MeshBasicMaterial).opacity =
+ 0.5 * Math.pow(1 - p, 1.5);
+ }
+ if (r2.current) {
+ const p = Math.min(1, Math.max(0, (tms - 90) / 380));
+ const s = 0.8 + easeOutCubic(p) * 0.9; // 0.8 → 1.7
+ r2.current.scale.set(s, s, 1);
+ (r2.current.material as THREE.MeshBasicMaterial).opacity =
+ tms < 90 ? 0 : 0.32 * Math.pow(1 - p, 1.5);
+ }
+ if (tms >= 470) {
+ // Fires exactly once — guarded by a ref, never a setState loop.
+ doneRef.current = true;
+ setDone(true);
+ }
+ });
+
+ if (done) return null;
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Outer shell: keeps the `!hover` early-return so nothing renders (and no
+ * per-frame work happens) when the pointer is off the board. All hooks live in
+ * `HoverPreviewInner`, which is only mounted when `hover` is non-null —
+ * see fx-spec §3.1.
+ */
+function HoverPreview({
+ hover,
+ player,
+ valid,
+ interactive,
+}: {
+ hover: Move | null;
+ player: Player;
+ valid: boolean;
+ interactive: boolean;
+}) {
+ if (!hover) return null;
+ return (
+
+ );
+}
+
+function HoverPreviewInner({
+ hover,
+ player,
+ valid,
+ interactive,
+}: {
+ hover: Move;
+ player: Player;
+ valid: boolean;
+ interactive: boolean;
+}) {
+ const ghost = useRef(null);
+ const matRef = useRef(null);
+ const [wx, , wz] = gridToWorld(hover.x, hover.y);
+ const color = stoneColor(player);
+
+ useFrame(({ clock }) => {
+ const mat = matRef.current;
+ const g = ghost.current;
+ if (!mat || !g) return;
+ let opacity: number;
+ let s = 1;
+ if (REDUCED) {
+ // §8.3-3: static, current behaviour.
+ opacity = valid ? 0.45 : 0.18;
+ } else if (!interactive) {
+ // §3.4: visible position, unmistakably "not your turn".
+ opacity = 0.12;
+ } else if (!valid) {
+ opacity = 0.18;
+ } else {
+ // §3.2: 0.9 Hz opacity breathing on an absolute clock phase (no jump
+ // when the preview hops between intersections).
+ const ph = Math.sin(TAU * 0.9 * clock.elapsedTime);
+ opacity = 0.38 + 0.1 * ph;
+ s = 1 + 0.015 * ph;
+ }
+ mat.opacity = opacity;
+ g.scale.set(s, STONE_FLAT, s);
+ });
+
+ // §3.4: the full-width crosshair is hidden while it is not the player's turn.
+ const showCross = REDUCED || interactive;
+
+ return (
+
+ {/* ghost stone */}
+
+
+
+
+ {/* guide crosshair through the hovered intersection (§3.3: static) */}
+ {showCross && (
+ <>
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+/**
+ * fx-spec §5.1: the bar is demoted to a backing layer (slow 0.5 Hz, narrower
+ * band) now that the per-stone travelling shimmer carries the victory read.
+ * Callers must guarantee `line.length >= 2`.
+ *
+ * `animated` is deliberately NOT "did the human win" — highlighting the five
+ * decisive stones is information, not celebration, so it runs for either side.
+ */
+function WinLine({ line, animated }: { line: Move[]; animated?: boolean }) {
+ const matRef = useRef(null);
+ const a = gridToWorld(line[0].x, line[0].y);
+ const b = gridToWorld(line[line.length - 1].x, line[line.length - 1].y);
+ const mid: [number, number, number] = [
+ (a[0] + b[0]) / 2,
+ 0.5,
+ (a[2] + b[2]) / 2,
+ ];
+ const len = Math.hypot(b[0] - a[0], b[2] - a[2]) + STONE_RADIUS;
+ const angle = Math.atan2(b[2] - a[2], b[0] - a[0]);
+
+ useFrame(({ clock }) => {
+ if (!matRef.current) return;
+ if (animated && !REDUCED) {
+ const t = (Math.sin(TAU * 0.5 * clock.elapsedTime) + 1) / 2; // 0..1
+ matRef.current.emissiveIntensity = 0.55 + t * 0.35; // 0.55..0.90
+ } else {
+ matRef.current.emissiveIntensity = 0.9;
+ }
+ });
+
+ return (
+
+
+
+
+ );
+}
+
+/** A soft, pulsing radial glow used during the victory celebration. */
+function CelebrationGlow() {
+ const ref = useRef(null);
+ useFrame(({ clock }) => {
+ if (!ref.current) return;
+ const t = REDUCED ? 0 : (Math.sin(clock.elapsedTime * 2) + 1) / 2; // 0..1
+ const s = 1 + t * 0.08;
+ ref.current.scale.set(s, s, s);
+ const mat = ref.current.material as THREE.MeshBasicMaterial;
+ mat.opacity = (REDUCED ? 0.12 : 0.1) + t * 0.06;
+ });
+ return (
+
+
+
+
+ );
+}
+
+/**
+ * fx-spec §4: last-move marker — an expanding outer ring plus a new static
+ * breathing inner ring. `fading` (§7.2 draw) runs a 400ms linear fade and then
+ * unmounts the whole group.
+ */
+function LastMovePulse({ move, fading }: { move: Move; fading?: boolean }) {
+ const outer = useRef(null);
+ const inner = useRef(null);
+ const fade = useRef(1);
+ const goneRef = useRef(false);
+ const [gone, setGone] = useState(false);
+ const [wx, , wz] = gridToWorld(move.x, move.y);
+
+ useFrame(({ clock }, delta) => {
+ if (goneRef.current) return;
+ if (fading) {
+ // §8.3-7b: reduced motion drops it instantly.
+ fade.current = REDUCED ? 0 : Math.max(0, fade.current - delta / 0.4);
+ if (fade.current <= 0) {
+ goneRef.current = true;
+ setGone(true);
+ return;
+ }
+ }
+ const f = fade.current;
+ if (inner.current) {
+ (inner.current.material as THREE.MeshBasicMaterial).opacity = REDUCED
+ ? // §8.3-4: static inner ring only.
+ 0.4 * f
+ : (0.35 + 0.15 * Math.sin(TAU * 0.7 * clock.elapsedTime)) * f;
+ }
+ if (!REDUCED && outer.current) {
+ const t = (clock.elapsedTime % 1.6) / 1.6;
+ const s = 0.75 + easeOutQuad(t) * 0.95; // 0.75 → 1.70
+ outer.current.scale.set(s, s, 1);
+ (outer.current.material as THREE.MeshBasicMaterial).opacity =
+ 0.5 * (1 - t) * f;
+ }
+ });
+
+ if (gone) return null;
+
+ return (
+
+ {!REDUCED && (
+
+
+
+
+ )}
+
+
+
+
+
+ );
+}
+
+/**
+ * fx-spec §6: a very quiet breathing frame around the board rim while the AI
+ * thinks. Four thin bars share a single material (their opacity is always in
+ * sync). Calls `onFadedOut` when the 450ms exit envelope reaches zero so the
+ * parent can unmount it and release the four draw calls.
+ */
+function ThinkingFrame({
+ thinking,
+ onFadedOut,
+}: {
+ thinking: boolean;
+ onFadedOut: () => void;
+}) {
+ const mat = useMemo(
+ () =>
+ new THREE.MeshBasicMaterial({
+ color: GUIDE_COLOR,
+ transparent: true,
+ opacity: 0,
+ depthWrite: false,
+ }),
+ []
+ );
+ useEffect(() => () => mat.dispose(), [mat]);
+ const lin = useRef(REDUCED && thinking ? 1 : 0);
+ const releasedRef = useRef(false);
+
+ useFrame(({ clock }, delta) => {
+ if (thinking) {
+ // A new AI turn starting before the exit envelope finished re-arms the
+ // one-shot release guard instead of leaving the frame permanently dead.
+ releasedRef.current = false;
+ // §8.3-6: reduced motion enters / leaves instantly.
+ lin.current = REDUCED ? 1 : Math.min(1, lin.current + delta / 0.3);
+ } else if (releasedRef.current) {
+ return;
+ } else {
+ lin.current = REDUCED ? 0 : Math.max(0, lin.current - delta / 0.45);
+ if (lin.current <= 0) {
+ mat.opacity = 0;
+ releasedRef.current = true;
+ onFadedOut();
+ return;
+ }
+ }
+ const env = thinking
+ ? easeOutQuad(lin.current)
+ : 1 - easeOutQuad(1 - lin.current);
+ mat.opacity = REDUCED
+ ? env * 0.14
+ : env * (0.12 + 0.12 * (Math.sin((TAU * clock.elapsedTime) / 2.4) + 1) / 2);
+ });
+
+ return (
+
+ {[-7.6, 7.6].map((z) => (
+
+
+
+ ))}
+ {[-7.6, 7.6].map((x) => (
+
+
+
+ ))}
+
+ );
+}
+
+function InteractionPlane({
+ interactive,
+ onHover,
+ onPlace,
+}: {
+ interactive: boolean;
+ onHover: (m: Move | null) => void;
+ onPlace: (m: Move) => void;
+}) {
+ const handle = (e: ThreeEvent) => {
+ const g = worldToGrid(e.point);
+ onHover(g);
+ return g;
+ };
+ return (
+ {
+ e.stopPropagation();
+ handle(e);
+ }}
+ onPointerOut={() => onHover(null)}
+ onClick={(e) => {
+ e.stopPropagation();
+ const g = worldToGrid(e.point);
+ if (g && interactive) onPlace(g);
+ }}
+ >
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Camera rig: tweens between the 3D orbit view and the locked 2D top-down view.
+// ---------------------------------------------------------------------------
+
+const CAM_3D = new THREE.Vector3(0, 16, 15);
+/** Tiny z offset avoids a degenerate lookAt (camera exactly on the up axis). */
+const CAM_2D = new THREE.Vector3(0, 22, 0.0001);
+const TWEEN_DURATION = 0.6; // seconds
+const POLAR_MIN_3D = 0.15;
+const POLAR_MAX_3D = Math.PI / 2.6;
+
+/**
+ * Apply per-mode OrbitControls constraints imperatively (the JSX props keep
+ * the 3D defaults; R3F only re-applies props when they change, so these
+ * imperative overrides are never clobbered by re-renders).
+ * - 2D: rotation hard-locked (enableRotate=false AND polar clamped to ~0,
+ * Spherical.makeSafe keeps phi at EPS so there is no gimbal lock). Zoom kept.
+ * - 3D: full original freedom restored.
+ */
+function applyModeConstraints(controls: OrbitControlsImpl, mode: ViewMode): void {
+ controls.enableRotate = mode === "3d";
+ controls.minPolarAngle = mode === "2d" ? 0 : POLAR_MIN_3D;
+ controls.maxPolarAngle = mode === "2d" ? 0 : POLAR_MAX_3D;
+}
+
+/**
+ * Drives the camera when `viewMode` changes: a 600ms easeInOutCubic position
+ * tween (instant when prefers-reduced-motion). During the tween the controls
+ * are disabled so drei's per-frame `controls.update()` cannot fight the tween
+ * (drei gates update() on `controls.enabled`). The Canvas is never remounted,
+ * so all scene/game state survives mode switches.
+ */
+function CameraRig({
+ viewMode,
+ onTweeningChange,
+}: {
+ viewMode: ViewMode;
+ onTweeningChange: (tweening: boolean) => void;
+}) {
+ const camera = useThree((s) => s.camera);
+ const controls = useThree((s) => s.controls) as OrbitControlsImpl | null;
+ const tween = useRef<{ from: THREE.Vector3; to: THREE.Vector3; t: number } | null>(
+ null
+ );
+ const appliedMode = useRef(null);
+
+ useEffect(() => {
+ const to = viewMode === "2d" ? CAM_2D : CAM_3D;
+
+ const snap = () => {
+ tween.current = null;
+ camera.position.copy(to);
+ camera.lookAt(0, 0, 0);
+ if (controls) {
+ applyModeConstraints(controls, viewMode);
+ controls.target.set(0, 0, 0);
+ controls.enabled = true;
+ controls.update();
+ }
+ onTweeningChange(false);
+ };
+
+ if (appliedMode.current === viewMode) {
+ // Same mode re-run (e.g. controls instance arrived after mount):
+ // just (re)apply constraints, never tween.
+ if (controls) {
+ applyModeConstraints(controls, viewMode);
+ controls.target.set(0, 0, 0);
+ controls.update();
+ }
+ return;
+ }
+
+ const isFirst = appliedMode.current === null;
+ appliedMode.current = viewMode;
+
+ if (isFirst || REDUCED) {
+ // Initial placement (persisted mode) or reduced motion: no animation.
+ snap();
+ return;
+ }
+
+ // Animated transition: freeze controls, tween from wherever the camera
+ // currently is (covers "user rotated in 3D, must return to exact top-down").
+ if (controls) controls.enabled = false;
+ tween.current = { from: camera.position.clone(), to: to.clone(), t: 0 };
+ onTweeningChange(true);
+ }, [viewMode, camera, controls, onTweeningChange]);
+
+ useFrame((_, delta) => {
+ const tw = tween.current;
+ if (!tw) return;
+ tw.t = Math.min(1, tw.t + delta / TWEEN_DURATION);
+ camera.position.lerpVectors(tw.from, tw.to, easeInOutCubic(tw.t));
+ camera.lookAt(0, 0, 0);
+ if (tw.t >= 1) {
+ tween.current = null;
+ if (controls) {
+ applyModeConstraints(controls, viewMode);
+ controls.target.set(0, 0, 0);
+ controls.enabled = true;
+ controls.update();
+ }
+ onTweeningChange(false);
+ }
+ });
+
+ return null;
+}
+
+export interface GameBoardProps {
+ board: Board;
+ currentPlayer: Player;
+ interactive: boolean;
+ lastMove: Move | null;
+ winLine: Move[] | null;
+ onHover: (m: Move | null) => void;
+ onPlace: (m: Move) => void;
+ /**
+ * The *human* won — purely celebratory extras (the ground glow) only.
+ * Win-line highlighting is informational and keys off `winLine` instead, so
+ * it still plays when the AI wins.
+ */
+ celebrate?: boolean;
+ /** "3d" = free orbit (default), "2d" = locked top-down, zoom only. */
+ viewMode?: ViewMode;
+ /** True while the AI is computing its move (drives the §6 thinking frame). */
+ thinking?: boolean;
+ /** Explicit draw signal from the App layer (`status === "draw"`). */
+ drawn?: boolean;
+}
+
+function Scene({
+ board,
+ currentPlayer,
+ interactive,
+ lastMove,
+ winLine,
+ onHover,
+ onPlace,
+ celebrate,
+ viewMode = "3d",
+ thinking,
+ drawn,
+}: GameBoardProps) {
+ const [hover, setHover] = useState(null);
+ // Explicit interaction policy while the camera tween runs: hover preview
+ // stays live (harmless), but placement is blocked to prevent mis-clicks on
+ // a moving camera. setCamTweening is a stable setState fn (safe effect dep).
+ const [camTweening, setCamTweening] = useState(false);
+ // The thinking frame outlives `thinking` by its 450ms exit envelope.
+ const [frameAlive, setFrameAlive] = useState(false);
+ // Frozen at first render: OrbitControls JSX props match the persisted mode
+ // so there is no 1-frame polar-clamp flicker at mount. All later mode
+ // changes are applied imperatively by CameraRig (R3F never re-applies
+ // unchanged JSX props, so the two never fight).
+ const initialMode = useRef(viewMode).current;
+ const hoverValid =
+ hover !== null && board[idx(hover.x, hover.y)] === 0;
+
+ const stones = useMemo(() => {
+ const list: { x: number; y: number; p: Player }[] = [];
+ for (let y = 0; y < 15; y++) {
+ for (let x = 0; x < 15; x++) {
+ const s = board[idx(x, y)];
+ if (s !== 0) list.push({ x, y, p: s as Player });
+ }
+ }
+ return list;
+ }, [board]);
+
+ // §5.1: winning-line order, resolved once per win instead of per stone.
+ // Gated on `winLine`, NOT on `celebrate`: the shimmer / hop / dimming answer
+ // "which five stones decided this game", which the loser needs to see just
+ // as much as the winner. Only confetti and the ground glow are celebratory.
+ const winIndexMap = useMemo(() => {
+ if (!winLine) return null;
+ const m = new Map();
+ winLine.forEach((mv, i) => m.set(`${mv.x}-${mv.y}`, i));
+ return m;
+ }, [winLine]);
+
+ useEffect(() => {
+ if (thinking) setFrameAlive(true);
+ }, [thinking]);
+ const releaseFrame = useCallback(() => setFrameAlive(false), []);
+
+ const hoverOnLastMove =
+ !!hover && !!lastMove && hover.x === lastMove.x && hover.y === lastMove.y;
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {
+ setHover(m);
+ onHover(m);
+ }}
+ onPlace={onPlace}
+ />
+
+ {/* board base */}
+
+
+
+
+ {/* soft grounding shadow under the floating board */}
+
+
+
+
+ {frameAlive && (
+
+ )}
+
+ {stones.map((s) => {
+ const key = `${s.x}-${s.y}`;
+ const isLast = !!lastMove && lastMove.x === s.x && lastMove.y === s.y;
+ const wi = winIndexMap?.get(key);
+ return (
+
+ );
+ })}
+
+ {/* §2: replays on every new lastMove (keyed), self-unmounts after 470ms */}
+ {lastMove && !REDUCED && (
+
+ )}
+
+ {/* §4.2 avoidance: never shown under a win, never fought with a same-cell hover */}
+ {lastMove && !winLine && !hoverOnLastMove && (
+
+ )}
+
+
+
+ {winLine && winLine.length >= 2 && (
+
+ )}
+
+ {celebrate && !REDUCED && }
+
+
+ >
+ );
+}
+
+export default function GameBoard(props: GameBoardProps) {
+ // Canvas `camera` is only read at creation; freeze the initial position so
+ // a persisted "2d" mode starts top-down without a first-frame 3D flash.
+ // Mode switches afterwards are handled by CameraRig — the Canvas itself is
+ // NEVER remounted, so the ongoing game / scene state is fully preserved.
+ const initialPos = useRef<[number, number, number]>(
+ (props.viewMode ?? "3d") === "2d"
+ ? [CAM_2D.x, CAM_2D.y, CAM_2D.z]
+ : [CAM_3D.x, CAM_3D.y, CAM_3D.z]
+ ).current;
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/plugins/gomoku-3d-ztools/src/components/LanguageSwitcher.tsx b/plugins/gomoku-3d-ztools/src/components/LanguageSwitcher.tsx
new file mode 100644
index 000000000..6d5b972fe
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/components/LanguageSwitcher.tsx
@@ -0,0 +1,35 @@
+import { Languages } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { Button } from "@/components/ui/button";
+import { play } from "@/lib/audio/sfx";
+import { writePersistent } from "@/lib/pluginHost";
+
+/**
+ * 切换中英文界面并将选择保存到 ZTools 插件存储。
+ * @returns 语言切换按钮。
+ */
+export function LanguageSwitcher() {
+ const { i18n, t } = useTranslation();
+ const isZh = (i18n.resolvedLanguage ?? i18n.language ?? "zh").startsWith("zh");
+
+ const toggle = () => {
+ const next = isZh ? "en" : "zh";
+ void i18n.changeLanguage(next);
+ writePersistent("language", next);
+ play("ui_click");
+ };
+
+ return (
+
+
+ {isZh ? "EN" : "中"}
+
+ );
+}
diff --git a/plugins/gomoku-3d-ztools/src/components/ReplayPanel.tsx b/plugins/gomoku-3d-ztools/src/components/ReplayPanel.tsx
new file mode 100644
index 000000000..2a1bfa389
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/components/ReplayPanel.tsx
@@ -0,0 +1,270 @@
+import {
+ Box,
+ ChevronLeft,
+ ChevronRight,
+ Download,
+ Film,
+ Grid3x3,
+ Pause,
+ Play,
+ RotateCw,
+ SkipBack,
+ SkipForward,
+ Volume2,
+ VolumeX,
+ X,
+} from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
+import { Slider } from "@/components/ui/slider";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+import { BLACK, type Player } from "@/lib/gomoku/types";
+import type { UseReplayReturn } from "@/hooks/useReplay";
+import type { ViewMode } from "@/hooks/useViewMode";
+
+const SPEEDS: { label: string; ms: number }[] = [
+ { label: "0.5×", ms: 1600 },
+ { label: "1×", ms: 800 },
+ { label: "2×", ms: 400 },
+ { label: "4×", ms: 200 },
+];
+
+export interface ReplayPanelProps {
+ replay: UseReplayReturn;
+ humanColor: Player;
+ muted: boolean;
+ onToggleMute: () => void;
+ viewMode: ViewMode;
+ onSwitchView: (m: ViewMode) => void;
+ onExport: () => void;
+ exportDisabled: boolean;
+ /** Mobile bottom-sheet variant: tighter paddings, same controls. */
+ compact?: boolean;
+}
+
+export default function ReplayPanel({
+ replay,
+ humanColor,
+ muted,
+ onToggleMute,
+ viewMode,
+ onSwitchView,
+ onExport,
+ exportDisabled,
+ compact = false,
+}: ReplayPanelProps) {
+ const { t } = useTranslation();
+ const { currentStep, N, moves } = replay;
+ const playing = replay.mode === "replaying-playing";
+ const atStart = currentStep === 0;
+ const atEnd = currentStep === N;
+ const currentMove = currentStep === 0 ? null : moves[currentStep - 1];
+
+ const info = currentMove
+ ? t("replay.moveInfo", {
+ step: currentStep,
+ label: currentMove.player === BLACK ? t("replay.black") : t("replay.white"),
+ player:
+ currentMove.player === humanColor
+ ? t("replay.player")
+ : t("replay.computer"),
+ }) + ` · (${currentMove.x}, ${currentMove.y})`
+ : t("replay.opening");
+
+ return (
+
+
+ {/* header: badge + exit */}
+
+
+ {t("replay.mode")}
+
+
+ {t("replay.exit")}
+
+
+
+ {/* current move info */}
+
+ {currentMove && (
+
+ )}
+ {info}
+
+
+ {/* progress */}
+
+ replay.goTo(v[0])}
+ className="flex-1"
+ />
+
+ {currentStep} / {N}
+
+
+
+ {/* transport controls */}
+
+
replay.goTo(0)}
+ >
+
+
+
+
+
+
+ {playing ? (
+
+ ) : atEnd ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
replay.goTo(N)}
+ >
+
+
+
+
+ {/* speed */}
+
+ {SPEEDS.map((s) => (
+ replay.setSpeed(s.ms)}
+ >
+ {s.label}
+
+ ))}
+
+
+
+
+ {/* companions that remain useful during replay */}
+
+
+ {muted ? (
+
+ ) : (
+
+ )}
+
+ onSwitchView(viewMode === "3d" ? "2d" : "3d")}
+ >
+ {viewMode === "2d" ? (
+
+ ) : (
+
+ )}
+
+
+ {t("replay.export")}
+
+
+
+
+ );
+}
diff --git a/plugins/gomoku-3d-ztools/src/components/ui/badge.tsx b/plugins/gomoku-3d-ztools/src/components/ui/badge.tsx
new file mode 100644
index 000000000..4c40ee11c
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground shadow",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground shadow",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/plugins/gomoku-3d-ztools/src/components/ui/button.tsx b/plugins/gomoku-3d-ztools/src/components/ui/button.tsx
new file mode 100644
index 000000000..fd042bf0f
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/components/ui/button.tsx
@@ -0,0 +1,117 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground shadow hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2",
+ sm: "h-8 rounded-md px-3 text-xs",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-9 w-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+ /** Disable the click ripple effect (e.g. for asChild wrappers). */
+ noRipple?: boolean;
+}
+
+interface Ripple {
+ id: number;
+ x: number;
+ y: number;
+ size: number;
+}
+
+const Button = React.forwardRef(
+ (
+ { className, variant, size, asChild = false, noRipple = false, onClick, children, ...props },
+ ref
+ ) => {
+ const [ripples, setRipples] = React.useState([]);
+
+ const handleClick = (e: React.MouseEvent) => {
+ if (!noRipple && !asChild) {
+ const el = e.currentTarget;
+ const rect = el.getBoundingClientRect();
+ const size = Math.max(rect.width, rect.height);
+ const x = e.clientX - rect.left - size / 2;
+ const y = e.clientY - rect.top - size / 2;
+ const id = performance.now() + Math.random();
+ setRipples((r) => [...r, { id, x, y, size }]);
+ window.setTimeout(
+ () => setRipples((r) => r.filter((rp) => rp.id !== id)),
+ 600
+ );
+ }
+ onClick?.(e);
+ };
+
+ const Comp = asChild ? Slot : "button";
+ return (
+
+ {asChild ? (
+ children
+ ) : (
+ <>
+
+ {children}
+
+
+ {ripples.map((r) => (
+
+ ))}
+
+ >
+ )}
+
+ );
+ }
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/plugins/gomoku-3d-ztools/src/components/ui/card.tsx b/plugins/gomoku-3d-ztools/src/components/ui/card.tsx
new file mode 100644
index 000000000..231a1350a
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/components/ui/card.tsx
@@ -0,0 +1,85 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & { interactive?: boolean }
+>(({ className, interactive, ...props }, ref) => (
+
+));
+Card.displayName = "Card";
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardHeader.displayName = "CardHeader";
+
+const CardTitle = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardTitle.displayName = "CardTitle";
+
+const CardDescription = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardDescription.displayName = "CardDescription";
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardContent.displayName = "CardContent";
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardFooter.displayName = "CardFooter";
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardDescription,
+ CardContent,
+};
diff --git a/plugins/gomoku-3d-ztools/src/components/ui/label.tsx b/plugins/gomoku-3d-ztools/src/components/ui/label.tsx
new file mode 100644
index 000000000..dcb69245f
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/components/ui/label.tsx
@@ -0,0 +1,21 @@
+import * as React from "react";
+import * as LabelPrimitive from "@radix-ui/react-label";
+
+import { cn } from "@/lib/utils";
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Label.displayName = LabelPrimitive.Root.displayName;
+
+export { Label };
diff --git a/plugins/gomoku-3d-ztools/src/components/ui/separator.tsx b/plugins/gomoku-3d-ztools/src/components/ui/separator.tsx
new file mode 100644
index 000000000..11b874188
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/components/ui/separator.tsx
@@ -0,0 +1,29 @@
+import * as React from "react";
+import * as SeparatorPrimitive from "@radix-ui/react-separator";
+
+import { cn } from "@/lib/utils";
+
+const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(
+ (
+ { className, orientation = "horizontal", decorative = true, ...props },
+ ref
+ ) => (
+
+ )
+);
+Separator.displayName = SeparatorPrimitive.Root.displayName;
+
+export { Separator };
diff --git a/plugins/gomoku-3d-ztools/src/components/ui/slider.tsx b/plugins/gomoku-3d-ztools/src/components/ui/slider.tsx
new file mode 100644
index 000000000..e0b780ecc
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/components/ui/slider.tsx
@@ -0,0 +1,26 @@
+import * as React from "react";
+import * as SliderPrimitive from "@radix-ui/react-slider";
+
+import { cn } from "@/lib/utils";
+
+const Slider = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+
+));
+Slider.displayName = SliderPrimitive.Root.displayName;
+
+export { Slider };
diff --git a/plugins/gomoku-3d-ztools/src/components/ui/switch.tsx b/plugins/gomoku-3d-ztools/src/components/ui/switch.tsx
new file mode 100644
index 000000000..f15014bd4
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/components/ui/switch.tsx
@@ -0,0 +1,27 @@
+import * as React from "react";
+import * as SwitchPrimitives from "@radix-ui/react-switch";
+
+import { cn } from "@/lib/utils";
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+Switch.displayName = SwitchPrimitives.Root.displayName;
+
+export { Switch };
diff --git a/plugins/gomoku-3d-ztools/src/components/ui/toast.tsx b/plugins/gomoku-3d-ztools/src/components/ui/toast.tsx
new file mode 100644
index 000000000..05106f5c9
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/components/ui/toast.tsx
@@ -0,0 +1,66 @@
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+
+export interface ToastItem {
+ id: number;
+ title: string;
+ desc?: string;
+ icon?: React.ReactNode;
+ toneClass?: string;
+}
+
+interface ToasterProps {
+ items: ToastItem[];
+ onDismiss: (id: number) => void;
+}
+
+export function Toaster({ items, onDismiss }: ToasterProps) {
+ const { t: tr } = useTranslation();
+ return (
+
+ {items.map((t) => (
+
+ {t.icon && (
+
+ {t.icon}
+
+ )}
+
+
+ {t.title}
+
+ {t.desc && (
+
{t.desc}
+ )}
+
+
onDismiss(t.id)}
+ aria-label={tr("toast.close")}
+ className="shrink-0 rounded-full p-1 text-stone-400 transition-colors hover:bg-stone-100 hover:text-stone-600"
+ >
+
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/plugins/gomoku-3d-ztools/src/hooks/useGomoku.ts b/plugins/gomoku-3d-ztools/src/hooks/useGomoku.ts
new file mode 100644
index 000000000..5b28b1ac5
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/hooks/useGomoku.ts
@@ -0,0 +1,461 @@
+import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Player,
+ Move,
+ GameStatus,
+ opponent,
+ validateMove,
+} from "@/lib/gomoku/types";
+import { getAIMove, type Difficulty } from "@/lib/gomoku/ai";
+import * as engine from "@/lib/gomoku/engine";
+import {
+ GameStats,
+ loadStats,
+ saveStats,
+ recordResult,
+ TONE_CLASSES,
+ type AchievementDef,
+} from "@/lib/gomoku/stats";
+import { play, unlockAudio, setMuted, isMuted } from "@/lib/audio/sfx";
+import { ACH_ICON } from "@/lib/achievementIcons";
+import { Sparkles, type LucideIcon } from "lucide-react";
+import { type ToastItem } from "@/components/ui/toast";
+import { usePluginActivity } from "@/hooks/usePluginActivity";
+import {
+ loadDifficulty,
+ loadElapsedMs,
+ loadGame,
+ loadHumanColor,
+ saveDifficulty,
+ saveElapsedMs,
+ saveGame,
+ saveHumanColor,
+} from "@/lib/gomoku/persistence";
+import { setAudioActive } from "@/lib/audio/sfx";
+
+export interface UseGomoku {
+ game: engine.GameState;
+ board: engine.GameState["board"];
+ currentPlayer: Player;
+ status: GameStatus;
+ lastMove: Move | null;
+ winLine: Move[] | null;
+ winner: Player | null;
+ moveCount: number;
+ difficulty: Difficulty;
+ humanColor: Player;
+ aiColor: Player;
+ thinking: boolean;
+ hover: Move | null;
+ stats: GameStats;
+ muted: boolean;
+ elapsedMs: number;
+ mmss: string;
+ toasts: ToastItem[];
+ interactive: boolean;
+ canUndo: boolean;
+ setDifficulty: (d: Difficulty) => void;
+ setHumanColor: (p: Player) => void;
+ setHover: (m: Move | null) => void;
+ handlePlace: (m: Move) => void;
+ restart: () => void;
+ undo: () => void;
+ toggleMute: () => void;
+ pushToast: (t: Omit) => void;
+ dismissToast: (id: number) => void;
+}
+
+export function useGomoku(): UseGomoku {
+ const { t } = useTranslation();
+ const pluginActive = usePluginActivity();
+ const [game, setGame] = useState(loadGame);
+ const [difficulty, setDifficultyState] = useState(loadDifficulty);
+ const [humanColor, setHumanColorState] = useState(loadHumanColor);
+ const [thinking, setThinking] = useState(false);
+ const [hover, setHoverState] = useState(null);
+ const [stats, setStats] = useState(() => loadStats());
+ const [muted, setMutedState] = useState(() => isMuted());
+ const [elapsedMs, setElapsedMs] = useState(loadElapsedMs);
+ const [toasts, setToasts] = useState([]);
+
+ const aiColor = opponent(humanColor);
+
+ // ---- ref mirrors to avoid stale closures ----
+ const gameRef = useRef(game);
+ gameRef.current = game;
+ const statusRef = useRef(game.status);
+ statusRef.current = game.status;
+ const aiColorRef = useRef(aiColor);
+ aiColorRef.current = aiColor;
+ const difficultyRef = useRef(difficulty);
+ difficultyRef.current = difficulty;
+ const humanColorRef = useRef(humanColor);
+ humanColorRef.current = humanColor;
+ const statsRef = useRef(stats);
+ statsRef.current = stats;
+ const thinkingRef = useRef(thinking);
+ thinkingRef.current = thinking;
+ const aiPendingRef = useRef(false);
+ const recordedRef = useRef(game.status !== "playing");
+ const timerRef = useRef(null);
+ const toastId = useRef(0);
+ const prevHoverRef = useRef(null);
+ const lastHoverPlayRef = useRef(0);
+ const thinkingWasTrueRef = useRef(false);
+
+ // ---- toast helpers ----
+ const pushToast = useCallback((t: Omit) => {
+ const id = ++toastId.current;
+ setToasts((prev) => [...prev, { ...t, id }]);
+ window.setTimeout(
+ () => setToasts((prev) => prev.filter((x) => x.id !== id)),
+ 4200
+ );
+ }, []);
+
+ const dismissToast = useCallback((id: number) => {
+ setToasts((prev) => prev.filter((x) => x.id !== id));
+ }, []);
+
+ const pushAchievementToast = useCallback(
+ (a: AchievementDef) => {
+ const Icon: LucideIcon = ACH_ICON[a.icon] ?? Sparkles;
+ const tone = TONE_CLASSES[a.tone];
+ pushToast({
+ title: `${t("toast.achievementUnlocked")} · ${t(`achievements.${a.id}.title`)}`,
+ desc: t(`achievements.${a.id}.desc`),
+ icon: createElement(Icon, { className: "h-5 w-5" }),
+ toneClass: `${tone.bg} ${tone.text}`,
+ });
+ },
+ [pushToast, t]
+ );
+
+ // ---- apply a move (shared by human + AI) ----
+ const applyMove = useCallback((m: Move, player: Player) => {
+ setGame((prev) => engine.placeStone(prev, m, player));
+ }, []);
+
+ const handlePlace = useCallback(
+ (m: Move) => {
+ if (statusRef.current !== "playing") return;
+ if (gameRef.current.currentPlayer !== humanColorRef.current) return;
+ if (thinkingRef.current) return;
+ if (!validateMove(gameRef.current.board, m)) return;
+ try {
+ unlockAudio();
+ play("place_player");
+ } catch {
+ /* ignore */
+ }
+ applyMove(m, humanColorRef.current);
+ },
+ [applyMove]
+ );
+
+ // ---- persist the resumable match and elapsed time in the host store ----
+ useEffect(() => saveGame(game), [game]);
+ useEffect(() => saveElapsedMs(elapsedMs), [elapsedMs]);
+
+ // ---- suspend sound when the plugin leaves the foreground ----
+ useEffect(() => {
+ setAudioActive(pluginActive);
+ if (!pluginActive) setHoverState(null);
+ return () => setAudioActive(false);
+ }, [pluginActive]);
+
+ // ---- AI turn: schedule exactly once per turn (no self-cancel bug) ----
+ // NOTE: `thinking` is intentionally NOT in the dependency array — including it
+ // would cancel the in-flight timeout and deadlock the AI.
+ useEffect(() => {
+ const isAITurn =
+ pluginActive && game.status === "playing" && game.currentPlayer === aiColor;
+ if (!isAITurn) {
+ aiPendingRef.current = false;
+ // BUG-QA-01 fix: switching 先手 mid-thinking cancels the AI timer (cleanup)
+ // and re-enters this branch; without resetting thinking the UI soft-locks.
+ setThinking(false);
+ return;
+ }
+ if (aiPendingRef.current) return;
+ aiPendingRef.current = true;
+ setThinking(true);
+ const delay = 320 + (difficulty === "master" ? 400 : 0);
+ const t = window.setTimeout(() => {
+ const m = getAIMove(gameRef.current.board, aiColor, difficultyRef.current);
+ aiPendingRef.current = false;
+ setThinking(false);
+ if (m) {
+ try {
+ play("place_ai");
+ } catch {
+ /* ignore */
+ }
+ applyMove(m, aiColor);
+ } else {
+ setGame((prev) => ({ ...prev, status: "draw", winner: null }));
+ }
+ }, delay);
+ return () => window.clearTimeout(t);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [pluginActive, game.status, game.currentPlayer, aiColor, applyMove]);
+
+ // ---- record finished game into stats + fire result / achievement sounds ----
+ useEffect(() => {
+ if (game.status === "playing") {
+ recordedRef.current = false;
+ return;
+ }
+ if (recordedRef.current) return;
+ recordedRef.current = true;
+
+ const outcome: "win" | "loss" | "draw" =
+ game.status === "draw"
+ ? "draw"
+ : game.winner === humanColorRef.current
+ ? "win"
+ : "loss";
+ const res = recordResult(statsRef.current, {
+ outcome,
+ moves: game.moveCount,
+ difficulty: difficultyRef.current,
+ });
+ setStats(res.stats);
+ saveStats(res.stats);
+
+ window.setTimeout(() => {
+ try {
+ if (outcome === "win") play("victory");
+ else if (outcome === "loss") play("defeat");
+ else play("draw");
+ } catch {
+ /* ignore */
+ }
+ }, 150);
+
+ res.unlocked.forEach((a, i) =>
+ window.setTimeout(
+ () => {
+ try {
+ play("achievement");
+ } catch {
+ /* ignore */
+ }
+ pushAchievementToast(a);
+ },
+ 250 + i * 350
+ )
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [game.status, pushAchievementToast]);
+
+ // ---- per-game timer ----
+ useEffect(() => {
+ if (!pluginActive || game.status !== "playing" || game.moveCount === 0) {
+ if (timerRef.current !== null) {
+ window.clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ return;
+ }
+ if (timerRef.current !== null) return; // already running
+ timerRef.current = window.setInterval(() => {
+ setElapsedMs((e) => e + 1000);
+ }, 1000);
+ }, [pluginActive, game.status, game.moveCount]);
+
+ useEffect(
+ () => () => {
+ if (timerRef.current !== null) window.clearInterval(timerRef.current);
+ },
+ []
+ );
+
+ // ---- thinking sound ----
+ useEffect(() => {
+ try {
+ if (thinking) {
+ play("thinking_start");
+ thinkingWasTrueRef.current = true;
+ } else if (thinkingWasTrueRef.current) {
+ play("thinking_end");
+ thinkingWasTrueRef.current = false;
+ }
+ } catch {
+ /* ignore */
+ }
+ }, [thinking]);
+
+ // ---- hover sound (throttled) ----
+ useEffect(() => {
+ const prev = prevHoverRef.current;
+ prevHoverRef.current = hover;
+ if (!hover) return;
+ if (prev && prev.x === hover.x && prev.y === hover.y) return;
+ const t = typeof performance !== "undefined" ? performance.now() : Date.now();
+ if (t - lastHoverPlayRef.current < 40) return;
+ lastHoverPlayRef.current = t;
+ try {
+ play("hover");
+ } catch {
+ /* ignore */
+ }
+ }, [hover]);
+
+ // ---- actions ----
+ const setDifficulty = useCallback((d: Difficulty) => {
+ try {
+ unlockAudio();
+ } catch {
+ /* ignore */
+ }
+ setDifficultyState(d);
+ saveDifficulty(d);
+ }, []);
+
+ const setHumanColor = useCallback((p: Player) => {
+ try {
+ unlockAudio();
+ play("ui_click");
+ } catch {
+ /* ignore */
+ }
+ setHumanColorState(p);
+ saveHumanColor(p);
+ }, []);
+
+ const setHover = useCallback((m: Move | null) => setHoverState(m), []);
+
+ const restart = useCallback(() => {
+ try {
+ unlockAudio();
+ play("ui_click");
+ } catch {
+ /* ignore */
+ }
+ setGame(engine.createGame());
+ setThinking(false);
+ aiPendingRef.current = false;
+ setHoverState(null);
+ recordedRef.current = false;
+ setElapsedMs(0);
+ if (timerRef.current !== null) {
+ window.clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ }, []);
+
+ const undo = useCallback(() => {
+ if (thinkingRef.current) return;
+ if (gameRef.current.status !== "playing") return;
+ if (gameRef.current.history.length < 2) return; // need a full round
+ try {
+ unlockAudio();
+ play("undo");
+ } catch {
+ /* ignore */
+ }
+ setGame((prev) => engine.undo(prev));
+ recordedRef.current = false;
+ }, []);
+
+ const toggleMute = useCallback(() => {
+ try {
+ unlockAudio();
+ } catch {
+ /* ignore */
+ }
+ setMutedState((prev) => {
+ const next = !prev;
+ if (prev === false && next === true) {
+ // muting: click is still audible, so play it before silencing.
+ try {
+ play("ui_click");
+ } catch {
+ /* ignore */
+ }
+ try {
+ setMuted(next);
+ } catch {
+ /* ignore */
+ }
+ } else {
+ // unmuting (muted→unmuted): re-enable sound first, then confirm tick.
+ try {
+ setMuted(next);
+ } catch {
+ /* ignore */
+ }
+ try {
+ play("unmute_tick");
+ } catch {
+ /* ignore */
+ }
+ }
+ return next;
+ });
+ }, []);
+
+ // ---- keyboard: Ctrl/Cmd+Z to undo ----
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if ((e.ctrlKey || e.metaKey) && (e.key === "z" || e.key === "Z")) {
+ e.preventDefault();
+ undo();
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [undo]);
+
+ // ---- derived ----
+ const interactive =
+ pluginActive &&
+ game.status === "playing" &&
+ game.currentPlayer === humanColor &&
+ !thinking;
+ const canUndo =
+ pluginActive &&
+ game.history.length >= 2 &&
+ game.status === "playing" &&
+ !thinking;
+ const mmss = useMemo(() => {
+ const total = Math.floor(elapsedMs / 1000);
+ const m = Math.floor(total / 60);
+ const s = total % 60;
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
+ }, [elapsedMs]);
+
+ return {
+ game,
+ board: game.board,
+ currentPlayer: game.currentPlayer,
+ status: game.status,
+ lastMove: game.lastMove,
+ winLine: game.winLine,
+ winner: game.winner,
+ moveCount: game.moveCount,
+ difficulty,
+ humanColor,
+ aiColor,
+ thinking,
+ hover,
+ stats,
+ muted,
+ elapsedMs,
+ mmss,
+ toasts,
+ interactive,
+ canUndo,
+ setDifficulty,
+ setHumanColor,
+ setHover,
+ handlePlace,
+ restart,
+ undo,
+ toggleMute,
+ pushToast,
+ dismissToast,
+ };
+}
diff --git a/plugins/gomoku-3d-ztools/src/hooks/usePluginActivity.ts b/plugins/gomoku-3d-ztools/src/hooks/usePluginActivity.ts
new file mode 100644
index 000000000..c9c8d5477
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/hooks/usePluginActivity.ts
@@ -0,0 +1,20 @@
+import { useEffect, useState } from "react";
+import { PLUGIN_ACTIVITY_EVENT } from "@/lib/pluginHost";
+
+/**
+ * 订阅插件前后台状态,供 AI、计时器和音频暂停使用。
+ * @returns 插件当前是否处于激活状态。
+ */
+export function usePluginActivity(): boolean {
+ const [active, setActive] = useState(() => window.__gomokuPluginActive !== false);
+
+ useEffect(() => {
+ const handleActivity = (event: Event) => {
+ setActive((event as CustomEvent).detail !== false);
+ };
+ window.addEventListener(PLUGIN_ACTIVITY_EVENT, handleActivity);
+ return () => window.removeEventListener(PLUGIN_ACTIVITY_EVENT, handleActivity);
+ }, []);
+
+ return active;
+}
diff --git a/plugins/gomoku-3d-ztools/src/hooks/useReplay.ts b/plugins/gomoku-3d-ztools/src/hooks/useReplay.ts
new file mode 100644
index 000000000..e0414fa21
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/hooks/useReplay.ts
@@ -0,0 +1,183 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Board, Move, createBoard } from "@/lib/gomoku/types";
+import type { GameState } from "@/lib/gomoku/engine";
+import {
+ boardAtStep,
+ deriveMoves,
+ snapshotGame,
+ type ReplayMove,
+ type ReplaySnapshot,
+} from "@/lib/gomoku/replay";
+
+export type ReplayMode = "idle" | "replaying-paused" | "replaying-playing";
+
+/** Everything GameBoard needs while replaying (mode !== "idle"). */
+export interface ReplayDisplay {
+ board: Board;
+ lastMove: Move | null;
+ winLine: Move[] | null;
+ interactive: boolean;
+ drawn: boolean;
+ celebrate: boolean;
+ thinking: boolean;
+ currentMove: ReplayMove | null;
+}
+
+export interface UseReplayReturn {
+ mode: ReplayMode;
+ currentStep: number;
+ speedMs: number;
+ N: number;
+ moves: ReplayMove[];
+ snapshot: ReplaySnapshot | null;
+ canEnterReplay: boolean;
+ display: ReplayDisplay;
+ enterReplay: () => void;
+ exitReplay: () => void;
+ stepForward: () => void;
+ stepBack: () => void;
+ goTo: (k: number) => void;
+ play: () => void;
+ pause: () => void;
+ togglePlay: () => void;
+ setSpeed: (ms: number) => void;
+}
+
+/**
+ * Read-only replay state machine over a frozen snapshot of the live game.
+ * The live `game` object is never touched; exiting replay simply renders the
+ * real state again.
+ */
+export function useReplay(game: GameState, thinking: boolean): UseReplayReturn {
+ const [mode, setMode] = useState("idle");
+ const [currentStep, setCurrentStep] = useState(0);
+ const [speedMs, setSpeedMs] = useState(800); // 1× = 800ms per move
+ const [snapshot, setSnapshot] = useState(null);
+ const [moves, setMoves] = useState([]);
+
+ const N = snapshot ? snapshot.moveCount : 0;
+
+ // ref mirrors so all actions stay referentially stable
+ const gameRef = useRef(game);
+ gameRef.current = game;
+ const thinkingRef = useRef(thinking);
+ thinkingRef.current = thinking;
+ const modeRef = useRef(mode);
+ modeRef.current = mode;
+ const nRef = useRef(N);
+ nRef.current = N;
+
+ const canEnterReplay = game.moveCount > 0 && !thinking;
+
+ const enterReplay = useCallback(() => {
+ if (gameRef.current.moveCount === 0 || thinkingRef.current) return;
+ const snap = snapshotGame(gameRef.current);
+ setSnapshot(snap);
+ setMoves(deriveMoves(snap));
+ setCurrentStep(0);
+ setMode("replaying-paused");
+ }, []);
+
+ const exitReplay = useCallback(() => {
+ setMode("idle");
+ setSnapshot(null);
+ setMoves([]);
+ setCurrentStep(0);
+ }, []);
+
+ const pause = useCallback(() => {
+ setMode((m) => (m === "replaying-playing" ? "replaying-paused" : m));
+ }, []);
+
+ const stepForward = useCallback(() => {
+ setCurrentStep((s) => Math.min(nRef.current, s + 1));
+ }, []);
+
+ const stepBack = useCallback(() => {
+ pause();
+ setCurrentStep((s) => Math.max(0, s - 1));
+ }, [pause]);
+
+ const goTo = useCallback(
+ (k: number) => {
+ pause();
+ setCurrentStep(Math.max(0, Math.min(nRef.current, Math.round(k))));
+ },
+ [pause]
+ );
+
+ const play = useCallback(() => {
+ if (modeRef.current === "idle") return;
+ // replay-from-start semantics when already at the last move
+ setCurrentStep((s) => (s >= nRef.current ? 0 : s));
+ setMode("replaying-playing");
+ }, []);
+
+ const togglePlay = useCallback(() => {
+ if (modeRef.current === "replaying-playing") pause();
+ else play();
+ }, [pause, play]);
+
+ const setSpeed = useCallback((ms: number) => setSpeedMs(ms), []);
+
+ // auto-play: rebuilt whenever mode or speed changes
+ useEffect(() => {
+ if (mode !== "replaying-playing") return;
+ const t = window.setInterval(() => stepForward(), speedMs);
+ return () => window.clearInterval(t);
+ }, [mode, speedMs, stepForward]);
+
+ // reaching the last move while playing → auto pause (clears the interval)
+ useEffect(() => {
+ if (mode === "replaying-playing" && currentStep >= N) pause();
+ }, [mode, currentStep, N, pause]);
+
+ const display: ReplayDisplay = useMemo(() => {
+ if (!snapshot) {
+ return {
+ board: createBoard(),
+ lastMove: null,
+ winLine: null,
+ interactive: false,
+ drawn: false,
+ celebrate: false,
+ thinking: false,
+ currentMove: null,
+ };
+ }
+ const currentMove = currentStep === 0 ? null : moves[currentStep - 1] ?? null;
+ const atEnd = currentStep === snapshot.moveCount;
+ return {
+ board: boardAtStep(snapshot, currentStep),
+ lastMove: currentMove ? { x: currentMove.x, y: currentMove.y } : null,
+ // the win line only exists at the very last step (mid-replay the game
+ // "had not been won yet")
+ winLine: atEnd && snapshot.winner ? snapshot.winLine : null,
+ interactive: false,
+ drawn: atEnd && snapshot.status === "draw",
+ celebrate: false,
+ thinking: false,
+ currentMove,
+ };
+ }, [snapshot, moves, currentStep]);
+
+ return {
+ mode,
+ currentStep,
+ speedMs,
+ N,
+ moves,
+ snapshot,
+ canEnterReplay,
+ display,
+ enterReplay,
+ exitReplay,
+ stepForward,
+ stepBack,
+ goTo,
+ play,
+ pause,
+ togglePlay,
+ setSpeed,
+ };
+}
diff --git a/plugins/gomoku-3d-ztools/src/hooks/useViewMode.ts b/plugins/gomoku-3d-ztools/src/hooks/useViewMode.ts
new file mode 100644
index 000000000..15b1379db
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/hooks/useViewMode.ts
@@ -0,0 +1,35 @@
+import { useCallback, useState } from "react";
+import { readPersistent, writePersistent } from "@/lib/pluginHost";
+
+/**
+ * Board view mode:
+ * - "3d": free orbit camera
+ * - "2d": locked top-down camera, rotation disabled, zoom kept
+ */
+export type ViewMode = "2d" | "3d";
+
+const STORAGE_KEY = "view-mode";
+
+/**
+ * 读取并校验持久化的棋盘视图。
+ * @returns 有效的 2D 或 3D 视图模式。
+ */
+function loadViewMode(): ViewMode {
+ const value = readPersistent(STORAGE_KEY, "3d");
+ return value === "2d" || value === "3d" ? value : "3d";
+}
+
+/**
+ * 提供持久化的棋盘视图状态。
+ * @returns 当前视图和更新函数。
+ */
+export function useViewMode(): [ViewMode, (mode: ViewMode) => void] {
+ const [viewMode, setViewModeState] = useState(loadViewMode);
+
+ const setViewMode = useCallback((mode: ViewMode) => {
+ setViewModeState(mode);
+ writePersistent(STORAGE_KEY, mode);
+ }, []);
+
+ return [viewMode, setViewMode];
+}
diff --git a/plugins/gomoku-3d-ztools/src/i18n/index.ts b/plugins/gomoku-3d-ztools/src/i18n/index.ts
new file mode 100644
index 000000000..a33ea5621
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/i18n/index.ts
@@ -0,0 +1,28 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import { readPersistent } from "@/lib/pluginHost";
+import { zh } from "./locales/zh";
+import { en } from "./locales/en";
+
+export const resources = {
+ zh: { translation: zh },
+ en: { translation: en },
+} as const;
+
+export const SUPPORTED_LANGS = ["zh", "en"] as const;
+export type Lang = (typeof SUPPORTED_LANGS)[number];
+
+const savedLanguage = readPersistent("language", "zh");
+const initialLanguage: Lang = savedLanguage === "en" ? "en" : "zh";
+
+i18n.use(initReactI18next).init({
+ resources,
+ lng: initialLanguage,
+ fallbackLng: "zh",
+ supportedLngs: SUPPORTED_LANGS as unknown as string[],
+ load: "languageOnly",
+ nonExplicitSupportedLngs: true,
+ interpolation: { escapeValue: false },
+});
+
+export default i18n;
diff --git a/plugins/gomoku-3d-ztools/src/i18n/locales/en.ts b/plugins/gomoku-3d-ztools/src/i18n/locales/en.ts
new file mode 100644
index 000000000..3708b2a52
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/i18n/locales/en.ts
@@ -0,0 +1,143 @@
+import type { Resources } from "./zh";
+
+// English text resources. Mirrors the shape of `zh` for full type-safety.
+export const en: Resources = {
+ header: {
+ title: "Gomoku",
+ brand: "3D",
+ subtitle: "Play vs AI · Hover preview · Guide lines",
+ sound: "Sound",
+ soundToggle: "Toggle sound",
+ documentTitle: "Gomoku 3D",
+ },
+ status: {
+ draw: "Draw",
+ youWin: "You win",
+ aiWin: "AI wins",
+ thinking: "AI thinking…",
+ yourTurn: "Your turn",
+ aiTurn: "AI's turn",
+ },
+ banner: {
+ draw: "Draw",
+ win: "You win · Five in a row!",
+ lose: "So close · Try again",
+ winLine: " · Winning line highlighted",
+ },
+ board: {
+ thinking: "AI thinking",
+ replayMode: "Replay",
+ replayReadonly: "Replaying · read-only",
+ yourTurn: "Your turn",
+ aiTurn: "AI's turn",
+ moves: "Moves",
+ time: "Time",
+ hint: "Hover over a board intersection to preview a translucent stone and orange guide lines",
+ },
+ difficulty: {
+ title: "Difficulty",
+ desc: "Higher is stronger — the AI attacks and defends",
+ current: "Current: {{name}}",
+ easy: { label: "Easy", hint: "Occasional slips" },
+ medium: { label: "Medium", hint: "Solid play" },
+ hard: { label: "Hard", hint: "Attack & defend" },
+ master: { label: "Master", hint: "Flawless calc" },
+ },
+ match: {
+ title: "Match",
+ humanFirst: "You first",
+ aiFirst: "AI first",
+ view: "Switch view",
+ view2d: "2D",
+ view3d: "3D",
+ view2dTitle: "2D top-down · rotation locked",
+ view3dTitle: "3D · free camera",
+ switchToggleFirst: "Toggle who goes first",
+ restart: "Restart",
+ undo: "Undo",
+ replay: "Replay",
+ replayThis: "Replay this game",
+ replayNone: "No moves to replay",
+ export: "Export",
+ exportNone: "No moves to export",
+ switchTo2d: "Switch to 2D view",
+ switchTo3d: "Switch to 3D view",
+ thinking: "AI thinking",
+ },
+ overlay: {
+ win: "Victory!",
+ lose: "Defeat",
+ draw: "Draw",
+ winDesc: "Five in a row — a beautiful finish!",
+ loseDesc: "So close. Rematch and turn it around.",
+ drawDesc: "Evenly matched — a worthy opponent.",
+ playAgain: "Play again",
+ replay: "Replay this game",
+ backToMenu: "Back to menu",
+ },
+ stats: {
+ title: "Record & Achievements",
+ win: "W",
+ loss: "L",
+ draw: "D",
+ streak: "Win streak",
+ },
+ replay: {
+ mode: "Replay",
+ exit: "Exit",
+ exitReplay: "Exit replay",
+ black: "Black",
+ white: "White",
+ progress: "Replay progress",
+ first: "First move",
+ prev: "Previous move",
+ play: "Play",
+ pause: "Pause",
+ replayAgain: "Replay",
+ next: "Next move",
+ last: "Last move",
+ speed: "Playback speed",
+ soundToggle: "Toggle sound",
+ view: "Switch view",
+ moveInfo: "Move {{step}} · {{label}} ({{player}})",
+ opening: "Start · empty board",
+ player: "You",
+ computer: "AI",
+ export: "Export",
+ },
+ export: {
+ title: "Export game",
+ format: "Format",
+ filename: "File name",
+ filenameLabel: "Export file name",
+ cancel: "Cancel",
+ export: "Export",
+ close: "Close",
+ emptyTip: "Nothing to export",
+ exported: "Exported {{filename}}",
+ exportedDesc: "{{count}} moves · {{format}} format",
+ exportFailed: "Export failed, please retry",
+ sgf: { desc: "Universal game format, imports into most Go/Gomoku/Renju apps" },
+ json: { desc: "Structured data with metadata, easy for programs to read and extend" },
+ txt: { desc: "Plain-text coordinate list, human-readable, easy to share" },
+ },
+ toast: {
+ close: "Close",
+ achievementUnlocked: "Achievement unlocked",
+ eggTitle: "Easter egg ✦",
+ eggDesc: "May your stones flow like the wind — five in a row!",
+ },
+ achievements: {
+ first_win: { title: "First Blood", desc: "Win your very first game" },
+ streak3: { title: "On a Roll", desc: "Win 3 games in a row" },
+ streak5: { title: "Penta Streak", desc: "Win 5 games in a row" },
+ speed: { title: "Blitz", desc: "Win in 24 moves or fewer" },
+ beat_hard: { title: "Dragon Slayer", desc: "Beat the AI on Hard" },
+ draw_master: { title: "Even Match", desc: "Draw a game" },
+ master_win: { title: "Crownless King", desc: "Beat the AI on Master" },
+ },
+ lang: {
+ label: "Language",
+ switch: "切换语言 / Switch language",
+ },
+};
diff --git a/plugins/gomoku-3d-ztools/src/i18n/locales/zh.ts b/plugins/gomoku-3d-ztools/src/i18n/locales/zh.ts
new file mode 100644
index 000000000..b981ac22b
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/i18n/locales/zh.ts
@@ -0,0 +1,143 @@
+// 简体中文文案资源。所有界面文本集中于此,禁止在组件内硬编码。
+export const zh = {
+ header: {
+ title: "五子棋",
+ brand: "Gomoku",
+ subtitle: "人机对战 · 悬浮预览 · 落子引导线",
+ sound: "音效",
+ soundToggle: "音效开关",
+ documentTitle: "五子棋 · Gomoku 3D",
+ },
+ status: {
+ draw: "平局",
+ youWin: "你 获胜",
+ aiWin: "AI 获胜",
+ thinking: "AI 思考中…",
+ yourTurn: "轮到你落子",
+ aiTurn: "AI 回合",
+ },
+ banner: {
+ draw: "平局",
+ win: "你赢了 · 五子连珠!",
+ lose: "惜败 · 再来一局",
+ winLine: " · 已高亮胜利连线",
+ },
+ board: {
+ thinking: "AI 思考中",
+ replayMode: "回放模式",
+ replayReadonly: "回放中 · 只读",
+ yourTurn: "轮到你",
+ aiTurn: "AI 回合",
+ moves: "手数",
+ time: "用时",
+ hint: "将鼠标移到棋盘交叉点,会显示半透明落子预览与橙色引导线",
+ },
+ difficulty: {
+ title: "难度",
+ desc: "越高越强,AI 会尝试进攻与防守",
+ current: "当前:{{name}}",
+ easy: { label: "简单", hint: "偶有失误" },
+ medium: { label: "中等", hint: "稳健应对" },
+ hard: { label: "困难", hint: "攻防兼备" },
+ master: { label: "大师", hint: "深算无懈" },
+ },
+ match: {
+ title: "对局",
+ humanFirst: "玩家先手",
+ aiFirst: "AI 先手",
+ view: "切换视图",
+ view2d: "2D 平面",
+ view3d: "3D 立体",
+ view2dTitle: "2D 俯视 · 锁定旋转",
+ view3dTitle: "3D 立体 · 自由视角",
+ switchToggleFirst: "切换先手",
+ restart: "重新开始",
+ undo: "悔棋",
+ replay: "回放",
+ replayThis: "回放本局",
+ replayNone: "暂无棋谱可回放",
+ export: "导出棋谱",
+ exportNone: "暂无棋谱可导出",
+ switchTo2d: "切换到 2D 视图",
+ switchTo3d: "切换到 3D 视图",
+ thinking: "AI 思考中",
+ },
+ overlay: {
+ win: "胜利!",
+ lose: "惜败",
+ draw: "平局",
+ winDesc: "五子连珠,漂亮的一手收官!",
+ loseDesc: "差之毫厘,再来一局扳回来。",
+ drawDesc: "势均力敌,棋逢对手。",
+ playAgain: "再来一局",
+ replay: "回放本局",
+ backToMenu: "返回菜单",
+ },
+ stats: {
+ title: "战绩 & 成就",
+ win: "胜",
+ loss: "负",
+ draw: "和",
+ streak: "当前连胜",
+ },
+ replay: {
+ mode: "回放模式",
+ exit: "退出",
+ exitReplay: "退出回放",
+ black: "黑",
+ white: "白",
+ progress: "回放进度",
+ first: "首手",
+ prev: "上一手",
+ play: "播放",
+ pause: "暂停",
+ replayAgain: "重播",
+ next: "下一手",
+ last: "末手",
+ speed: "播放速度",
+ soundToggle: "音效开关",
+ view: "切换视图",
+ moveInfo: "第 {{step}} 手 · {{label}}({{player}})",
+ opening: "开局 · 空盘",
+ player: "玩家",
+ computer: "电脑",
+ export: "导出棋谱",
+ },
+ export: {
+ title: "导出棋谱",
+ format: "格式",
+ filename: "文件名",
+ filenameLabel: "导出文件名",
+ cancel: "取消",
+ export: "导出",
+ close: "关闭",
+ emptyTip: "空对局无法导出",
+ exported: "已导出 {{filename}}",
+ exportedDesc: "共 {{count}} 手 · {{format}} 格式",
+ exportFailed: "导出失败,请重试",
+ sgf: { desc: "通用棋谱格式,可导入绝大多数围棋/五子棋/连珠软件" },
+ json: { desc: "结构化数据,含元信息,便于程序读取与二次开发" },
+ txt: { desc: "纯文本坐标列表,人类可读,便于粘贴分享" },
+ },
+ toast: {
+ close: "关闭",
+ achievementUnlocked: "成就解锁",
+ eggTitle: "彩蛋 ✦",
+ eggDesc: "愿你落子如风,五子连珠!",
+ },
+ achievements: {
+ first_win: { title: "初露锋芒", desc: "赢下你的第一盘对局" },
+ streak3: { title: "小有斩获", desc: "连续获胜 3 局" },
+ streak5: { title: "五连胜王", desc: "连续获胜 5 局" },
+ speed: { title: "雷霆一击", desc: "在 24 手之内取胜" },
+ beat_hard: { title: "屠龙者", desc: "在困难难度下击败 AI" },
+ draw_master: { title: "棋逢对手", desc: "下成一盘和棋" },
+ master_win: { title: "无冕之王", desc: "在大师难度下击败 AI" },
+ },
+ lang: {
+ label: "语言",
+ switch: "切换语言 / Switch language",
+ },
+};
+
+export type Resources = typeof zh;
diff --git a/plugins/gomoku-3d-ztools/src/index.css b/plugins/gomoku-3d-ztools/src/index.css
new file mode 100644
index 000000000..6e5991ea6
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/index.css
@@ -0,0 +1,282 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+@config "../tailwind.config.js";
+
+@layer base {
+ :root {
+ --background: 30 33% 98%;
+ --foreground: 20 14% 12%;
+ --card: 0 0% 100%;
+ --card-foreground: 20 14% 12%;
+ --primary: 24 95% 53%;
+ --primary-foreground: 0 0% 100%;
+ --secondary: 30 20% 92%;
+ --secondary-foreground: 20 14% 20%;
+ --muted: 30 16% 94%;
+ --muted-foreground: 25 10% 45%;
+ --accent: 24 90% 95%;
+ --accent-foreground: 24 80% 35%;
+ --destructive: 0 72% 51%;
+ --destructive-foreground: 0 0% 100%;
+ --border: 30 15% 86%;
+ --input: 30 15% 86%;
+ --ring: 24 95% 53%;
+ --radius: 0.75rem;
+ }
+
+ .dark {
+ --background: 20 14% 10%;
+ --foreground: 30 10% 96%;
+ --card: 20 14% 13%;
+ --card-foreground: 30 10% 96%;
+ --primary: 24 95% 55%;
+ --primary-foreground: 20 14% 8%;
+ --secondary: 20 10% 20%;
+ --secondary-foreground: 30 10% 92%;
+ --muted: 20 10% 18%;
+ --muted-foreground: 25 8% 60%;
+ --accent: 24 50% 22%;
+ --accent-foreground: 24 90% 80%;
+ --destructive: 0 62% 50%;
+ --destructive-foreground: 0 0% 100%;
+ --border: 20 10% 24%;
+ --input: 20 10% 24%;
+ --ring: 24 95% 55%;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ html,
+ body,
+ #root {
+ min-height: 100%;
+ background: transparent;
+ }
+ body {
+ @apply text-foreground antialiased;
+ margin: 0;
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
+ "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif;
+ }
+}
+
+/* ============================================================
+ Design tokens: layered shadows + glow for depth & hierarchy
+ ============================================================ */
+:root {
+ --shadow-soft: 0 1px 2px rgba(60, 40, 20, 0.06),
+ 0 4px 12px -2px rgba(60, 40, 20, 0.08);
+ --shadow-card: 0 2px 8px rgba(60, 40, 20, 0.07),
+ 0 18px 40px -16px rgba(60, 40, 20, 0.22);
+ --shadow-float: 0 12px 30px -12px rgba(234, 88, 12, 0.32),
+ 0 6px 14px -6px rgba(60, 40, 20, 0.12);
+ --shadow-glow: 0 0 0 1px rgba(234, 88, 12, 0.25),
+ 0 10px 28px -8px rgba(234, 88, 12, 0.45);
+}
+
+/* ============================================================
+ Keyframes
+ ============================================================ */
+@keyframes shimmer {
+ 100% {
+ transform: translateX(100%);
+ }
+}
+@keyframes float-bob {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-8px);
+ }
+}
+@keyframes pop-in {
+ 0% {
+ transform: scale(0.7);
+ opacity: 0;
+ }
+ 60% {
+ transform: scale(1.06);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(1);
+ }
+}
+@keyframes fade-up {
+ 0% {
+ transform: translateY(10px);
+ opacity: 0;
+ }
+ 100% {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+@keyframes pulse-ring {
+ 0% {
+ transform: scale(0.85);
+ opacity: 0.85;
+ }
+ 70% {
+ transform: scale(1.8);
+ opacity: 0;
+ }
+ 100% {
+ transform: scale(1.8);
+ opacity: 0;
+ }
+}
+@keyframes wiggle {
+ 0%,
+ 100% {
+ transform: rotate(0deg);
+ }
+ 25% {
+ transform: rotate(-7deg);
+ }
+ 75% {
+ transform: rotate(7deg);
+ }
+}
+@keyframes twinkle {
+ 0%,
+ 100% {
+ opacity: 0.25;
+ }
+ 50% {
+ opacity: 0.9;
+ }
+}
+@keyframes aurora-shift {
+ 0% {
+ transform: translate3d(-6%, -4%, 0) scale(1.1);
+ }
+ 50% {
+ transform: translate3d(6%, 4%, 0) scale(1.25);
+ }
+ 100% {
+ transform: translate3d(-6%, -4%, 0) scale(1.1);
+ }
+}
+@keyframes bounce-dot {
+ 0%,
+ 80%,
+ 100% {
+ transform: translateY(0);
+ opacity: 0.5;
+ }
+ 40% {
+ transform: translateY(-5px);
+ opacity: 1;
+ }
+}
+@keyframes confetti-fall {
+ 0% {
+ transform: translate(0, 0) rotate(0deg);
+ opacity: 1;
+ }
+ 100% {
+ transform: translate(var(--cx, 0), var(--cy, -120px)) rotate(540deg);
+ opacity: 0;
+ }
+}
+
+/* ============================================================
+ Utility classes
+ ============================================================ */
+@layer components {
+ .animate-shimmer {
+ animation: shimmer 1.6s infinite;
+ }
+ .animate-float {
+ animation: float-bob 5s ease-in-out infinite;
+ }
+ .animate-pop-in {
+ animation: pop-in 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both;
+ }
+ .animate-fade-up {
+ animation: fade-up 0.5s ease-out both;
+ }
+ .animate-wiggle {
+ animation: wiggle 0.6s ease-in-out;
+ }
+ .animate-twinkle {
+ animation: twinkle 3.5s ease-in-out infinite;
+ }
+ .animate-aurora {
+ animation: aurora-shift 18s ease-in-out infinite;
+ }
+
+ /* Frosted-glass panel */
+ .glass {
+ background: linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0.82),
+ rgba(255, 252, 247, 0.72)
+ );
+ backdrop-filter: blur(14px) saturate(140%);
+ -webkit-backdrop-filter: blur(14px) saturate(140%);
+ border: 1px solid rgba(255, 255, 255, 0.6);
+ box-shadow: var(--shadow-card);
+ }
+
+ /* Animated multi-stop gradient text for the brand */
+ .text-gradient {
+ background: linear-gradient(
+ 100deg,
+ #ea580c,
+ #f59e0b 35%,
+ #f97316 60%,
+ #d946ef 100%
+ );
+ background-size: 200% auto;
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+ animation: bg-pan 12s linear infinite;
+ }
+ @keyframes bg-pan {
+ to {
+ background-position: 200% center;
+ }
+ }
+
+ /* Shimmering skeleton block */
+ .skeleton {
+ position: relative;
+ overflow: hidden;
+ background: rgba(120, 90, 60, 0.08);
+ }
+ .skeleton::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ transform: translateX(-100%);
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(255, 255, 255, 0.55),
+ transparent
+ );
+ animation: shimmer 1.6s infinite;
+ }
+}
+
+/* ============================================================
+ Respect users who prefer reduced motion (a11y + perf)
+ ============================================================ */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.001ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.001ms !important;
+ scroll-behavior: auto !important;
+ }
+}
diff --git a/plugins/gomoku-3d-ztools/src/lib/achievementIcons.ts b/plugins/gomoku-3d-ztools/src/lib/achievementIcons.ts
new file mode 100644
index 000000000..ccfb52f78
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/lib/achievementIcons.ts
@@ -0,0 +1,20 @@
+import {
+ Sparkles,
+ Flame,
+ Crown,
+ Zap,
+ Sword,
+ Handshake,
+ type LucideIcon,
+} from "lucide-react";
+
+/** Maps an achievement's `icon` string to a lucide component. Kept in one place
+ * so the toasts (hook) and the achievement grid (App) stay in sync. */
+export const ACH_ICON: Record = {
+ Sparkles,
+ Flame,
+ Crown,
+ Zap,
+ Sword,
+ Handshake,
+};
diff --git a/plugins/gomoku-3d-ztools/src/lib/audio/sfx.ts b/plugins/gomoku-3d-ztools/src/lib/audio/sfx.ts
new file mode 100644
index 000000000..56696f260
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/lib/audio/sfx.ts
@@ -0,0 +1,350 @@
+/**
+ * Zero-dependency Web Audio sound engine. Synthesizes all UI / game sounds from
+ * oscillators + filtered noise. The AudioContext is created lazily on first use
+ * and resumed inside a user gesture via `unlockAudio`. Everything degrades
+ * gracefully (no-ops) when audio is unavailable (SSR, privacy mode, failures).
+ */
+
+import { readPersistent, writePersistent } from "@/lib/pluginHost";
+
+export type SfxEvent =
+ | "place_player"
+ | "place_ai"
+ | "victory"
+ | "defeat"
+ | "draw"
+ | "ui_click"
+ | "achievement"
+ | "hover"
+ | "thinking_start"
+ | "thinking_end"
+ | "undo"
+ | "unmute_tick";
+
+const STORAGE_KEY = "audio-muted";
+
+let ctx: AudioContext | null = null;
+let masterGain: GainNode | null = null;
+let muted = false;
+let active = true;
+
+function getCtx(): AudioContext | null {
+ if (ctx) return ctx;
+ try {
+ const AC: typeof AudioContext | undefined =
+ typeof window !== "undefined"
+ ? window.AudioContext ||
+ (window as unknown as { webkitAudioContext?: typeof AudioContext })
+ .webkitAudioContext
+ : undefined;
+ if (!AC) return null;
+ ctx = new AC();
+ masterGain = ctx.createGain();
+ masterGain.gain.value = muted || !active ? 0 : 0.7;
+
+ const lp = ctx.createBiquadFilter();
+ lp.type = "lowpass";
+ lp.frequency.value = 6000;
+
+ const comp = ctx.createDynamicsCompressor();
+ comp.threshold.value = -18;
+ comp.ratio.value = 4;
+ comp.knee.value = 12;
+
+ masterGain.connect(lp);
+ lp.connect(comp);
+ comp.connect(ctx.destination);
+ } catch {
+ ctx = null;
+ masterGain = null;
+ }
+ return ctx;
+}
+
+/** Resume the context inside a user gesture (required by browser autoplay). */
+export function unlockAudio(): void {
+ const c = getCtx();
+ if (!c) return;
+ try {
+ if (c.state === "suspended") void c.resume();
+ } catch {
+ /* ignore */
+ }
+}
+
+/** Toggle mute. Persisted in the ZTools plugin store; smoothing avoids a click. */
+export function setMuted(value: boolean): void {
+ muted = value;
+ writePersistent(STORAGE_KEY, value);
+ if (masterGain && ctx) {
+ try {
+ masterGain.gain.setTargetAtTime(value || !active ? 0 : 0.7, ctx.currentTime, 0.02);
+ } catch {
+ /* ignore */
+ }
+ }
+}
+
+/**
+ * Pause or resume all generated audio with the plugin lifecycle.
+ * @param value Whether the plugin is currently active.
+ * @returns No value.
+ */
+export function setAudioActive(value: boolean): void {
+ active = value;
+ if (masterGain && ctx) {
+ try {
+ masterGain.gain.setTargetAtTime(muted || !active ? 0 : 0.7, ctx.currentTime, 0.02);
+ if (!active && ctx.state === "running") void ctx.suspend();
+ if (active && ctx.state === "suspended") void ctx.resume();
+ } catch {
+ /* ignore */
+ }
+ }
+}
+
+export function isMuted(): boolean {
+ return muted;
+}
+
+function initMutedFromStorage(): void {
+ muted = readPersistent(STORAGE_KEY, false) === true;
+}
+initMutedFromStorage();
+
+function makeNoise(c: AudioContext, dur: number): AudioBufferSourceNode {
+ const len = Math.max(1, Math.floor(c.sampleRate * dur));
+ const buf = c.createBuffer(1, len, c.sampleRate);
+ const data = buf.getChannelData(0);
+ for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
+ const src = c.createBufferSource();
+ src.buffer = buf;
+ return src;
+}
+
+interface BlipOpts {
+ type?: OscillatorType;
+ f0: number;
+ f1?: number; // glide target (exponential)
+ t0: number; // absolute ctx time
+ dur: number; // seconds
+ a: number; // attack (s)
+ d: number; // decay to ~0 (s)
+ peak: number;
+ lp?: number; // lowpass cutoff
+ noise?: { hp: number; gain: number; dur: number; t: number };
+ sub?: { f: number; gain: number; type?: OscillatorType };
+ detune?: number; // cents slide (e.g. +4% up)
+ partials?: { mult: number; gain: number; type?: OscillatorType }[];
+}
+
+function blip(c: AudioContext, o: BlipOpts): void {
+ const now = o.t0;
+ const g = c.createGain();
+ g.gain.setValueAtTime(0.0001, now);
+ g.gain.linearRampToValueAtTime(o.peak, now + o.a);
+ g.gain.linearRampToValueAtTime(0.0001, now + o.a + o.d);
+
+ let node: AudioNode = g;
+ let lp: BiquadFilterNode | null = null;
+ if (o.lp) {
+ lp = c.createBiquadFilter();
+ lp.type = "lowpass";
+ lp.frequency.value = o.lp;
+ g.connect(lp);
+ node = lp;
+ }
+ node.connect(masterGain!);
+
+ const spawnOsc = (
+ freq: number,
+ type: OscillatorType,
+ gain: number,
+ glideTo?: number
+ ): void => {
+ const osc = c.createOscillator();
+ osc.type = type;
+ osc.frequency.setValueAtTime(freq, now);
+ if (glideTo !== undefined) {
+ osc.frequency.exponentialRampToValueAtTime(
+ Math.max(0.0001, glideTo),
+ now + o.a + o.d
+ );
+ }
+ if (o.detune) {
+ osc.detune.setValueAtTime(0, now);
+ osc.detune.linearRampToValueAtTime(o.detune, now + o.dur);
+ }
+ const og = c.createGain();
+ og.gain.value = gain;
+ osc.connect(og);
+ og.connect(g);
+ osc.start(now);
+ osc.stop(now + o.dur + 0.02);
+ osc.onended = () => {
+ try {
+ osc.disconnect();
+ og.disconnect();
+ } catch {
+ /* ignore */
+ }
+ };
+ };
+
+ spawnOsc(o.f0, o.type ?? "triangle", 1, o.f1);
+ if (o.partials) {
+ for (const p of o.partials) {
+ spawnOsc(
+ o.f0 * p.mult,
+ p.type ?? "sine",
+ p.gain,
+ o.f1 !== undefined ? o.f1 * p.mult : undefined
+ );
+ }
+ }
+
+ if (o.sub) {
+ const so = c.createOscillator();
+ so.type = o.sub.type ?? "sine";
+ so.frequency.setValueAtTime(o.sub.f, now);
+ const sg = c.createGain();
+ sg.gain.setValueAtTime(0.0001, now);
+ sg.gain.linearRampToValueAtTime(o.sub.gain, now + o.a);
+ sg.gain.linearRampToValueAtTime(0.0001, now + o.dur);
+ so.connect(sg);
+ sg.connect(masterGain!);
+ so.start(now);
+ so.stop(now + o.dur + 0.02);
+ so.onended = () => {
+ try {
+ so.disconnect();
+ sg.disconnect();
+ } catch {
+ /* ignore */
+ }
+ };
+ }
+
+ if (o.noise) {
+ const nd = o.noise;
+ const n = makeNoise(c, nd.dur);
+ const ng = c.createGain();
+ ng.gain.setValueAtTime(nd.gain, now + nd.t);
+ ng.gain.linearRampToValueAtTime(0.0001, now + nd.t + nd.dur);
+ let nn: AudioNode = n;
+ let hp: BiquadFilterNode | null = null;
+ if (nd.hp) {
+ hp = c.createBiquadFilter();
+ hp.type = "highpass";
+ hp.frequency.value = nd.hp;
+ n.connect(hp);
+ nn = hp;
+ }
+ nn.connect(ng);
+ ng.connect(masterGain!);
+ n.start(now + nd.t);
+ n.stop(now + nd.t + nd.dur + 0.02);
+ n.onended = () => {
+ try {
+ n.disconnect();
+ ng.disconnect();
+ if (hp) hp.disconnect();
+ } catch {
+ /* ignore */
+ }
+ };
+ }
+
+ const stopMs = (o.dur + 0.1) * 1000;
+ window.setTimeout(
+ () => {
+ try {
+ g.disconnect();
+ if (lp) lp.disconnect();
+ } catch {
+ /* ignore */
+ }
+ },
+ stopMs
+ );
+}
+
+/** Play a synthesized sound event. No-op when muted or audio is unavailable. */
+export function play(event: SfxEvent): void {
+ if (!active || muted) return;
+ const c = getCtx();
+ if (!c) return;
+ try {
+ const t = c.currentTime;
+ switch (event) {
+ case "place_player":
+ blip(c, { type: "triangle", f0: 480, f1: 230, t0: t, dur: 0.13, a: 0.002, d: 0.07, peak: 0.5, lp: 4200, noise: { hp: 2000, gain: 0.15, dur: 0.005, t: 0 } });
+ break;
+ case "place_ai":
+ blip(c, { type: "triangle", f0: 360, f1: 170, t0: t, dur: 0.15, a: 0.002, d: 0.08, peak: 0.42, lp: 3200 });
+ break;
+ case "victory": {
+ const notes = [523, 659, 784, 1046];
+ const offs = [0, 0.09, 0.18, 0.28];
+ notes.forEach((f, i) =>
+ blip(c, { type: "triangle", f0: f, t0: t + offs[i], dur: 0.358, a: 0.008, d: 0.1, peak: 0.4, lp: 6000 })
+ );
+ blip(c, { type: "sine", f0: 261, t0: t, dur: 0.7, a: 0.01, d: 0.4, peak: 0.3, lp: 2000 });
+ blip(c, { type: "triangle", f0: 1568, t0: t + 0.28, dur: 0.4, a: 0.008, d: 0.2, peak: 0.12, lp: 7000 });
+ break;
+ }
+ case "defeat": {
+ const notes = [294, 233, 196];
+ const offs = [0, 0.14, 0.28];
+ notes.forEach((f, i) =>
+ blip(c, { type: "triangle", f0: f, t0: t + offs[i], dur: 0.59, a: 0.02, d: 0.18, peak: 0.35, lp: 1800 })
+ );
+ blip(c, { type: "sine", f0: 110, t0: t, dur: 0.8, a: 0.02, d: 0.5, peak: 0.1, lp: 600 });
+ break;
+ }
+ case "draw": {
+ blip(c, { type: "triangle", f0: 587, f1: 440, t0: t, dur: 0.36, a: 0.015, d: 0.14, peak: 0.32, lp: 2600 });
+ blip(c, { type: "triangle", f0: 440, t0: t + 0.16, dur: 0.36, a: 0.015, d: 0.14, peak: 0.32, lp: 2600 });
+ break;
+ }
+ case "ui_click":
+ blip(c, { type: "triangle", f0: 880, f1: 660, t0: t, dur: 0.06, a: 0.001, d: 0.03, peak: 0.25, lp: 5000, noise: { hp: 2000, gain: 0.08, dur: 0.003, t: 0 } });
+ break;
+ case "achievement":
+ blip(c, {
+ type: "triangle", f0: 988, f1: 988 * 1.04, t0: t, dur: 0.6, a: 0.005, d: 0.09, peak: 0.35, lp: 7000,
+ partials: [
+ { mult: 2.0, gain: 0.12 },
+ { mult: 2.76, gain: 0.07 },
+ ],
+ });
+ blip(c, {
+ type: "sine", f0: 1319, f1: 1319 * 1.04, t0: t + 0.11, dur: 0.6, a: 0.005, d: 0.09, peak: 0.3, lp: 7000,
+ partials: [
+ { mult: 2.0, gain: 0.1 },
+ { mult: 2.76, gain: 0.06 },
+ ],
+ });
+ break;
+ case "hover":
+ blip(c, { type: "triangle", f0: 300, t0: t, dur: 0.08, a: 0.005, d: 0.04, peak: 0.05, lp: 1500 });
+ break;
+ case "thinking_start":
+ blip(c, { type: "triangle", f0: 440, f1: 587, t0: t, dur: 0.12, a: 0.005, d: 0.06, peak: 0.11, lp: 3000 });
+ break;
+ case "thinking_end":
+ blip(c, { type: "triangle", f0: 587, f1: 440, t0: t, dur: 0.13, a: 0.005, d: 0.07, peak: 0.11, lp: 3000 });
+ break;
+ case "undo":
+ // triangle 240→180Hz glide; soft lowpassed "step back" thunk.
+ blip(c, { type: "triangle", f0: 240, f1: 180, t0: t, dur: 0.14, a: 0.003, d: 0.09, peak: 0.4, lp: 2200 });
+ break;
+ case "unmute_tick":
+ // short confirmation tick when sound returns from muted.
+ blip(c, { type: "triangle", f0: 660, f1: 550, t0: t, dur: 0.06, a: 0.001, d: 0.03, peak: 0.12, lp: 5000 });
+ break;
+ }
+ } catch {
+ /* audio failures must never break gameplay */
+ }
+}
diff --git a/plugins/gomoku-3d-ztools/src/lib/gomoku/ai.ts b/plugins/gomoku-3d-ztools/src/lib/gomoku/ai.ts
new file mode 100644
index 000000000..83526de3d
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/lib/gomoku/ai.ts
@@ -0,0 +1,391 @@
+import {
+ Board,
+ Move,
+ Player,
+ opponent,
+ idx,
+ inBounds,
+ getCandidateMoves,
+ checkWinFrom,
+} from "./types";
+
+export type Difficulty = "easy" | "medium" | "hard" | "master";
+
+const FIVE = 10_000_000;
+const OPEN_FOUR = 1_000_000;
+const FOUR = 100_000;
+const OPEN_THREE = 10_000;
+const THREE = 1_000;
+const OPEN_TWO = 100;
+const TWO = 10;
+
+/**
+ * Ordered by descending score. Score = first pattern (highest) found inside
+ * the 9-cell line window. Line chars: '1' = our stone, '2' = opponent/edge
+ * (blocks), '0' = empty.
+ */
+const PATTERNS: ReadonlyArray<{ p: string; s: number }> = [
+ { p: "11111", s: FIVE },
+ { p: "011110", s: OPEN_FOUR },
+ { p: "11011", s: FOUR },
+ { p: "10111", s: FOUR },
+ { p: "11101", s: FOUR },
+ { p: "011112", s: FOUR },
+ { p: "211110", s: FOUR },
+ { p: "11110", s: FOUR },
+ { p: "01111", s: FOUR },
+ { p: "010110", s: OPEN_THREE },
+ { p: "011010", s: OPEN_THREE },
+ { p: "011100", s: OPEN_THREE },
+ { p: "001110", s: OPEN_THREE },
+ { p: "11100", s: THREE },
+ { p: "00111", s: THREE },
+ { p: "11010", s: THREE },
+ { p: "01011", s: THREE },
+ { p: "10110", s: THREE },
+ { p: "01101", s: THREE },
+ { p: "11001", s: THREE },
+ { p: "10011", s: THREE },
+ { p: "10101", s: THREE },
+ { p: "011000", s: OPEN_TWO },
+ { p: "000110", s: OPEN_TWO },
+ { p: "001100", s: OPEN_TWO },
+ { p: "010100", s: OPEN_TWO },
+ { p: "001010", s: OPEN_TWO },
+ { p: "11000", s: TWO },
+ { p: "00011", s: TWO },
+ { p: "10100", s: TWO },
+ { p: "00101", s: TWO },
+ { p: "10010", s: TWO },
+ { p: "01010", s: TWO },
+];
+
+function bestPatternInLine(line: string): number {
+ for (const { p, s } of PATTERNS) {
+ if (line.includes(p)) return s;
+ }
+ return 0;
+}
+
+const DIRS: ReadonlyArray<[number, number]> = [
+ [1, 0],
+ [0, 1],
+ [1, 1],
+ [1, -1],
+];
+
+/** Score of the shape formed by placing `player` at (x, y). */
+export function evaluatePoint(
+ board: Board,
+ x: number,
+ y: number,
+ player: Player
+): number {
+ let total = 0;
+ for (const [dx, dy] of DIRS) {
+ let line = "";
+ for (let i = -4; i <= 4; i++) {
+ if (i === 0) {
+ line += "1";
+ continue;
+ }
+ const nx = x + dx * i;
+ const ny = y + dy * i;
+ if (!inBounds(nx, ny)) {
+ line += "2";
+ } else {
+ const s = board[idx(nx, ny)];
+ if (s === player) line += "1";
+ else if (s === 0) line += "0";
+ else line += "2";
+ }
+ }
+ total += bestPatternInLine(line);
+ }
+ return total;
+}
+
+function wouldWin(board: Board, m: Move, player: Player): boolean {
+ board[idx(m.x, m.y)] = player;
+ const win = checkWinFrom(board, m.x, m.y, player);
+ board[idx(m.x, m.y)] = 0;
+ return win;
+}
+
+function moveScore(
+ board: Board,
+ m: Move,
+ player: Player,
+ defWeight: number
+): number {
+ const offense = evaluatePoint(board, m.x, m.y, player);
+ const defense = evaluatePoint(board, m.x, m.y, opponent(player));
+ return offense + defWeight * defense;
+}
+
+function greedy(
+ board: Board,
+ candidates: Move[],
+ player: Player,
+ defWeight: number
+): Move {
+ let best = candidates[0];
+ let bestScore = -Infinity;
+ for (const m of candidates) {
+ const s = moveScore(board, m, player, defWeight);
+ if (s > bestScore) {
+ bestScore = s;
+ best = m;
+ }
+ }
+ return best;
+}
+
+function search2Ply(
+ board: Board,
+ candidates: Move[],
+ player: Player,
+ defWeight: number,
+ K: number
+): Move {
+ const opp = opponent(player);
+ const scored = candidates
+ .map((m) => ({ m, s: moveScore(board, m, player, defWeight) }))
+ .sort((a, b) => b.s - a.s);
+ const top = scored.slice(0, K).map((e) => e.m);
+
+ let best = top[0];
+ let bestScore = -Infinity;
+ for (const m of top) {
+ board[idx(m.x, m.y)] = player;
+ // opponent's best reply
+ let oppBest = -Infinity;
+ const oppMoves = getCandidateMoves(board);
+ for (const om of oppMoves) {
+ const s = moveScore(board, om, opp, defWeight);
+ if (s > oppBest) oppBest = s;
+ }
+ const score = moveScore(board, m, player, defWeight) - 0.8 * oppBest;
+ board[idx(m.x, m.y)] = 0;
+ if (score > bestScore) {
+ bestScore = score;
+ best = m;
+ }
+ }
+ return best;
+}
+
+interface DifficultyConfig {
+ defWeight: number;
+ searchDepth: number;
+ candidateK: number;
+ randomness: number;
+ threatForcing: boolean;
+ forkAware: boolean;
+}
+
+/** Difficulty-driven tuning table. The "must win / must block" rules in
+ * `getAIMove` are always applied regardless of `threatForcing`. */
+const DIFFICULTY_CONFIG: Record = {
+ easy: { defWeight: 0.5, searchDepth: 0, candidateK: 4, randomness: 0.7, threatForcing: false, forkAware: false },
+ medium: { defWeight: 1.0, searchDepth: 1, candidateK: 8, randomness: 0.1, threatForcing: false, forkAware: false },
+ hard: { defWeight: 1.0, searchDepth: 2, candidateK: 14, randomness: 0.02, threatForcing: true, forkAware: false },
+ master: { defWeight: 1.05, searchDepth: 4, candidateK: 20, randomness: 0, threatForcing: true, forkAware: true },
+};
+
+/* ----------------------------------------------------------------------------
+ * Fork awareness (master only): localized, dimension-aligned double-threat
+ * recognition. We count the *immediate winning threats* a posted move grants
+ * (empty points that would complete five), on the same scale as `moveScore`,
+ * instead of adding flat hundred-thousand-level global constants. This keeps the
+ * evaluation on one coherent scale and makes the look-ahead strictly dominate.
+ * ------------------------------------------------------------------------- */
+
+function now(): number {
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
+}
+
+const WIN = 1_000_000_000;
+
+// Leaf-evaluation weights, on the `moveScore` scale. Steep gaps make building a
+// four / open-four dominate small shape noise, so the search prefers forcing
+// lines — a dimension-aligned alternative to the old global fork constants.
+const T_WIN = 10_000_000; // immediate winning threat
+const T_OPEN_FOUR = 1_000_000;
+const T_FOUR = 100_000;
+const T_OPEN_THREE = 10_000;
+const T_THREE = 1_000;
+const T_TWO = 100;
+
+/**
+ * Threat potential of `player` on the current board: weighted shape value summed
+ * over every candidate point (what `player` could build there). Counts fours,
+ * open-threes and forks, giving the search a real positional sense instead of a
+ * single best-move heuristic.
+ */
+function evalSide(board: Board, player: Player): number {
+ let win = 0;
+ let openFour = 0;
+ let four = 0;
+ let openThree = 0;
+ let three = 0;
+ let two = 0;
+ for (const m of getCandidateMoves(board)) {
+ const e = evaluatePoint(board, m.x, m.y, player);
+ if (e >= FIVE) win++;
+ else if (e >= OPEN_FOUR) openFour++;
+ else if (e >= FOUR) four++;
+ else if (e >= OPEN_THREE) openThree++;
+ else if (e >= THREE) three++;
+ else if (e >= OPEN_TWO) two++;
+ }
+ return (
+ win * T_WIN +
+ openFour * T_OPEN_FOUR +
+ four * T_FOUR +
+ openThree * T_OPEN_THREE +
+ three * T_THREE +
+ two * T_TWO
+ );
+}
+
+/** Leaf heuristic from the perspective of the side to move. */
+function evaluateBoard(board: Board, player: Player, defWeight: number): number {
+ return evalSide(board, player) - defWeight * evalSide(board, opponent(player));
+}
+
+/**
+ * Alpha-beta negamax with beam pruning. Returns the value of the position from
+ * the perspective of `player` (the side to move at this node). A win is returned
+ * as `WIN`; a full board is a draw (0). `K` caps how many candidate moves are
+ * expanded at each node so the search stays within the time budget.
+ */
+function negamax(
+ board: Board,
+ player: Player,
+ defWeight: number,
+ depth: number,
+ K: number,
+ alpha: number,
+ beta: number
+): number {
+ if (depth === 0) return evaluateBoard(board, player, defWeight);
+ const cands = getCandidateMoves(board);
+ if (cands.length === 0) return 0; // full board -> draw
+ const ranked = cands
+ .map((m) => ({ m, s: moveScore(board, m, player, defWeight) }))
+ .sort((a, b) => b.s - a.s)
+ .slice(0, K);
+ let best = -Infinity;
+ for (const { m } of ranked) {
+ board[idx(m.x, m.y)] = player;
+ let val: number;
+ if (checkWinFrom(board, m.x, m.y, player)) {
+ val = WIN;
+ } else {
+ val = -negamax(board, opponent(player), defWeight, depth - 1, K, -beta, -alpha);
+ }
+ board[idx(m.x, m.y)] = 0;
+ if (val > best) best = val;
+ if (best > alpha) alpha = best;
+ if (alpha >= beta) break; // prune
+ }
+ return best;
+}
+
+/**
+ * Master search: top-K root candidates (by pattern score), bounded by an ~800ms
+ * time budget, evaluated with an alpha-beta minimax that is strictly deeper than
+ * the hard tier's 2-ply look-ahead, using a threat-counting leaf evaluation.
+ *
+ * Always returns a legal move; never null (callers guarantee non-empty input).
+ */
+function searchMaster(
+ board: Board,
+ candidates: Move[],
+ player: Player,
+ defWeight: number,
+ K: number
+): Move {
+ const opp = opponent(player);
+ const t0 = now();
+ const TIME_BUDGET = 800;
+ const DEPTH = 4; // plies after the root move -> a 5-ply search (hard is 2-ply)
+ const DEEP_K = 3; // beam width inside the tree
+ const ROOT_K = Math.min(K, 8);
+
+ const scored = candidates
+ .map((m) => ({ m, s: moveScore(board, m, player, defWeight) }))
+ .sort((a, b) => b.s - a.s);
+ const top = scored.slice(0, ROOT_K);
+
+ let best = top[0].m;
+ let bestScore = -Infinity;
+ for (const { m } of top) {
+ if (now() - t0 > TIME_BUDGET) break;
+ board[idx(m.x, m.y)] = player;
+ let val: number;
+ if (checkWinFrom(board, m.x, m.y, player)) {
+ val = WIN;
+ } else {
+ val = -negamax(board, opp, defWeight, DEPTH, DEEP_K, -Infinity, Infinity);
+ }
+ board[idx(m.x, m.y)] = 0;
+ if (val > bestScore) {
+ bestScore = val;
+ best = m;
+ }
+ }
+ return best;
+}
+
+/**
+ * Choose the AI's move for `player`. Guarantees: take an immediate win, and
+ * block the opponent's immediate win (the "must" rules, kept for every tier).
+ * Then search by difficulty. Returns `null` only when no candidate exists
+ * (a full board → caller should declare a draw).
+ */
+export function getAIMove(
+ board: Board,
+ player: Player,
+ difficulty: Difficulty
+): Move | null {
+ const candidates = getCandidateMoves(board);
+ if (candidates.length === 0) return null;
+
+ const opp = opponent(player);
+
+ // 1. Take an immediate win.
+ for (const m of candidates) {
+ if (wouldWin(board, m, player)) return m;
+ }
+ // 2. Block the opponent's immediate win.
+ for (const m of candidates) {
+ if (wouldWin(board, m, opp)) return m;
+ }
+
+ const cfg = DIFFICULTY_CONFIG[difficulty];
+
+ if (difficulty === "easy") {
+ const ranked = candidates
+ .map((m) => ({ m, s: moveScore(board, m, player, cfg.defWeight) }))
+ .sort((a, b) => b.s - a.s);
+ const top = ranked.slice(0, cfg.candidateK);
+ // Often a careless blunder among all cells; otherwise among the top few.
+ if (Math.random() < cfg.randomness) {
+ return candidates[Math.floor(Math.random() * candidates.length)];
+ }
+ return top[Math.floor(Math.random() * top.length)].m;
+ }
+
+ if (difficulty === "medium") {
+ return greedy(board, candidates, player, cfg.defWeight);
+ }
+
+ if (difficulty === "hard") {
+ return search2Ply(board, candidates, player, cfg.defWeight, cfg.candidateK);
+ }
+
+ // master: fork-aware, time-bounded 3-ply minimax (strictly stronger than hard).
+ return searchMaster(board, candidates, player, cfg.defWeight, cfg.candidateK);
+}
diff --git a/plugins/gomoku-3d-ztools/src/lib/gomoku/engine.ts b/plugins/gomoku-3d-ztools/src/lib/gomoku/engine.ts
new file mode 100644
index 000000000..1f52bd3a6
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/lib/gomoku/engine.ts
@@ -0,0 +1,123 @@
+import {
+ Board,
+ Player,
+ Move,
+ GameStatus,
+ Stone,
+ BLACK,
+ opponent,
+ createBoard,
+ idx,
+ checkWinFrom,
+ isFull,
+ getWinningLine,
+ validateMove,
+} from "./types";
+
+/** Immutable snapshot taken *before* a move is applied. Used to support undo. */
+export interface HistoryEntry {
+ board: Board;
+ currentPlayer: Player;
+ lastMove: Move | null;
+ winLine: Move[] | null;
+ status: GameStatus;
+ winner: Player | null;
+ moveCount: number;
+}
+
+/** The complete, immutable game state. All transitions go through pure helpers
+ * in this module so the React layer can stay a thin view over the engine. */
+export interface GameState {
+ board: Board;
+ currentPlayer: Player;
+ status: GameStatus;
+ lastMove: Move | null;
+ winLine: Move[] | null;
+ winner: Player | null;
+ moveCount: number;
+ history: HistoryEntry[];
+}
+
+/** A fresh game: empty board, BLACK to move, no history. */
+export function createGame(): GameState {
+ return {
+ board: createBoard(),
+ currentPlayer: BLACK,
+ status: "playing",
+ lastMove: null,
+ winLine: null,
+ winner: null,
+ moveCount: 0,
+ history: [],
+ };
+}
+
+/**
+ * Apply `player`'s move. Pure + immutable: returns a brand-new state and never
+ * mutates the input. Boundary defense — any of these makes us return the input
+ * state unchanged (no throw): not your turn, game already over, out of bounds,
+ * or the cell is occupied.
+ */
+export function placeStone(
+ state: GameState,
+ move: Move,
+ player: Player
+): GameState {
+ if (state.status !== "playing") return state;
+ if (player !== state.currentPlayer) return state;
+ if (!validateMove(state.board, move)) return state;
+
+ const snapshot: HistoryEntry = {
+ board: state.board.slice(),
+ currentPlayer: state.currentPlayer,
+ lastMove: state.lastMove,
+ winLine: state.winLine,
+ status: state.status,
+ winner: state.winner,
+ moveCount: state.moveCount,
+ };
+
+ const board = state.board.slice();
+ board[idx(move.x, move.y)] = player as Stone;
+
+ const next: GameState = {
+ ...state,
+ board,
+ lastMove: move,
+ moveCount: state.moveCount + 1,
+ history: [...state.history, snapshot],
+ };
+
+ if (checkWinFrom(board, move.x, move.y, player)) {
+ next.status = player === BLACK ? "black_win" : "white_win";
+ next.winner = player;
+ next.winLine = getWinningLine(board, move.x, move.y, player);
+ return next;
+ }
+ if (isFull(board)) {
+ next.status = "draw";
+ return next;
+ }
+ next.currentPlayer = opponent(player);
+ return next;
+}
+
+/** Whether an undo is currently possible. */
+export function canUndo(state: GameState): boolean {
+ return state.history.length > 0 && state.status === "playing";
+}
+
+/**
+ * Revert a full round (the AI's reply + the human's move), returning to the
+ * state just before the human last moved — i.e. it's the human's turn again.
+ * If only one move is on the board we revert that single move. When the game
+ * is over or there is nothing to revert we return the state unchanged.
+ */
+export function undo(state: GameState): GameState {
+ if (state.history.length === 0 || state.status !== "playing") return state;
+ const h = state.history.slice();
+ const restoreIdx = h.length >= 2 ? h.length - 2 : 0;
+ const restored = h[restoreIdx];
+ const newHistory = h.slice(0, restoreIdx);
+ return { ...restored, history: newHistory };
+}
diff --git a/plugins/gomoku-3d-ztools/src/lib/gomoku/export.ts b/plugins/gomoku-3d-ztools/src/lib/gomoku/export.ts
new file mode 100644
index 000000000..af3adcbce
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/lib/gomoku/export.ts
@@ -0,0 +1,194 @@
+import { Player, BLACK, BOARD_SIZE } from "./types";
+import type { GameState } from "./engine";
+import type { ReplayMove } from "./replay";
+
+export type ExportFormat = "sgf" | "json" | "txt";
+
+export interface ExportMeta {
+ blackName: string;
+ whiteName: string;
+ /** SGF-style result: "B+W" | "W+W" | "0" (draw) | "?" (unfinished). */
+ result: string;
+ /** Local date, YYYY-MM-DD. */
+ date: string;
+ boardSize: number;
+ moveCount: number;
+}
+
+/**
+ * SGF game type. GM[4] = Gomoku+Renju per the SGF FF[4] spec. GM[1] (Go) is
+ * also recognised by many Go tools if broader compatibility is ever needed.
+ */
+const SGF_GM = 4;
+
+const EXT: Record = {
+ sgf: ".sgf",
+ json: ".json",
+ txt: ".txt",
+};
+
+const MIME: Record = {
+ sgf: "application/x-go-sgf",
+ json: "application/json",
+ txt: "text/plain",
+};
+
+function pad2(n: number): string {
+ return String(n).padStart(2, "0");
+}
+
+export function buildMeta(game: GameState, humanColor: Player): ExportMeta {
+ const result =
+ game.status === "black_win"
+ ? "B+W"
+ : game.status === "white_win"
+ ? "W+W"
+ : game.status === "draw"
+ ? "0"
+ : "?";
+ const d = new Date();
+ const date = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
+ const humanIsBlack = humanColor === BLACK;
+ return {
+ blackName: humanIsBlack ? "玩家" : "电脑",
+ whiteName: humanIsBlack ? "电脑" : "玩家",
+ result,
+ date,
+ boardSize: BOARD_SIZE,
+ moveCount: game.moveCount,
+ };
+}
+
+export function resultToChinese(result: string): string {
+ switch (result) {
+ case "B+W":
+ return "黑胜";
+ case "W+W":
+ return "白胜";
+ case "0":
+ return "平局";
+ default:
+ return "未结束";
+ }
+}
+
+/** SGF coordinate: letters a–o, x first then y. (7,7) → "hh". */
+function sgfCoord(x: number, y: number): string {
+ return String.fromCharCode(97 + x) + String.fromCharCode(97 + y);
+}
+
+export function toSGF(moves: ReplayMove[], meta: ExportMeta): string {
+ const head =
+ `(;GM[${SGF_GM}]FF[4]CA[UTF-8]SZ[${meta.boardSize}]AP[Gomoku3D:1.0]` +
+ `DT[${meta.date}]PB[${meta.blackName}]PW[${meta.whiteName}]RE[${meta.result}]`;
+ const body = moves
+ .map((m) => `;${m.player === BLACK ? "B" : "W"}[${sgfCoord(m.x, m.y)}]`)
+ .join("");
+ return head + body + ")";
+}
+
+export function toJSON(moves: ReplayMove[], meta: ExportMeta): string {
+ return JSON.stringify(
+ {
+ meta: {
+ app: "Gomoku3D",
+ boardSize: meta.boardSize,
+ blackName: meta.blackName,
+ whiteName: meta.whiteName,
+ result: meta.result,
+ date: meta.date,
+ moveCount: meta.moveCount,
+ },
+ moves: moves.map((m) => ({
+ index: m.index,
+ x: m.x,
+ y: m.y,
+ player: m.player,
+ label: m.label,
+ })),
+ },
+ null,
+ 2
+ );
+}
+
+export function toTXT(moves: ReplayMove[], meta: ExportMeta): string {
+ const lines = [
+ `五子棋棋谱 ${meta.date} 黑:${meta.blackName} 白:${meta.whiteName} 结果:${resultToChinese(meta.result)}`,
+ ];
+ for (const m of moves) lines.push(`${m.index}. ${m.label} (${m.x},${m.y})`);
+ return lines.join("\n") + "\n";
+}
+
+export function buildExportText(
+ moves: ReplayMove[],
+ meta: ExportMeta,
+ format: ExportFormat
+): string {
+ switch (format) {
+ case "sgf":
+ return toSGF(moves, meta);
+ case "json":
+ return toJSON(moves, meta);
+ case "txt":
+ return toTXT(moves, meta);
+ }
+}
+
+/** Replace characters illegal in file names with "-". Empty stays empty. */
+export function sanitizeFileName(name: string): string {
+ return name.replace(/[/\\:*?"<>|]/g, "-");
+}
+
+/** `gomoku-YYYYMMDD-HHMMSS` in local time. */
+export function defaultFileName(): string {
+ const d = new Date();
+ return (
+ `gomoku-${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}` +
+ `-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`
+ );
+}
+
+/** Native download: Blob + a[download]. No runtime dependency. */
+export function downloadTextFile(
+ filename: string,
+ text: string,
+ mime: string
+): void {
+ const blob = new Blob([text], { type: mime + ";charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+}
+
+/**
+ * Build text + filename for `format`, trigger the browser download and
+ * return what was written (useful for the success toast).
+ */
+export async function exportGame(
+ moves: ReplayMove[],
+ meta: ExportMeta,
+ format: ExportFormat,
+ baseName?: string
+): Promise<{ cancelled: boolean; filename: string; text: string; path?: string }> {
+ const base = sanitizeFileName(baseName ?? "") || defaultFileName();
+ const text = buildExportText(moves, meta, format);
+ const filename = base + EXT[format];
+
+ if (window.gomokuBridge?.saveTextFile) {
+ const result = await window.gomokuBridge.saveTextFile({
+ format,
+ suggestedName: filename,
+ text,
+ });
+ return { cancelled: result.cancelled, filename, text, path: result.path };
+ }
+
+ downloadTextFile(filename, text, MIME[format]);
+ return { cancelled: false, filename, text };
+}
diff --git a/plugins/gomoku-3d-ztools/src/lib/gomoku/persistence.ts b/plugins/gomoku-3d-ztools/src/lib/gomoku/persistence.ts
new file mode 100644
index 000000000..6fedd30f2
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/lib/gomoku/persistence.ts
@@ -0,0 +1,171 @@
+import type { Difficulty } from "./ai";
+import type { GameState, HistoryEntry } from "./engine";
+import {
+ BLACK,
+ BOARD_SIZE,
+ WHITE,
+ type Board,
+ type GameStatus,
+ type Move,
+ type Player,
+} from "./types";
+import { createGame } from "./engine";
+import { readPersistent, writePersistent } from "@/lib/pluginHost";
+
+const GAME_KEY = "current-game";
+const DIFFICULTY_KEY = "difficulty";
+const HUMAN_COLOR_KEY = "human-color";
+const ELAPSED_KEY = "elapsed-ms";
+const STATUS_VALUES = new Set(["playing", "black_win", "white_win", "draw"]);
+const DIFFICULTY_VALUES = new Set(["easy", "medium", "hard", "master"]);
+
+/**
+ * 校验棋盘数组。
+ * @param value 待校验数据。
+ * @returns 数据是否为合法的 15×15 棋盘。
+ */
+function isBoard(value: unknown): value is Board {
+ return (
+ Array.isArray(value) &&
+ value.length === BOARD_SIZE * BOARD_SIZE &&
+ value.every((stone) => stone === 0 || stone === BLACK || stone === WHITE)
+ );
+}
+
+/**
+ * 校验棋盘坐标。
+ * @param value 待校验数据。
+ * @returns 数据是否为合法坐标或空值。
+ */
+function isMoveOrNull(value: unknown): value is Move | null {
+ if (value === null) return true;
+ if (!value || typeof value !== "object") return false;
+ const move = value as Move;
+ return (
+ Number.isInteger(move.x) &&
+ Number.isInteger(move.y) &&
+ move.x >= 0 &&
+ move.x < BOARD_SIZE &&
+ move.y >= 0 &&
+ move.y < BOARD_SIZE
+ );
+}
+
+/**
+ * 校验历史快照。
+ * @param value 待校验数据。
+ * @returns 数据是否为合法历史快照。
+ */
+function isHistoryEntry(value: unknown): value is HistoryEntry {
+ if (!value || typeof value !== "object") return false;
+ const entry = value as HistoryEntry;
+ return (
+ isBoard(entry.board) &&
+ (entry.currentPlayer === BLACK || entry.currentPlayer === WHITE) &&
+ isMoveOrNull(entry.lastMove) &&
+ (entry.winLine === null || (Array.isArray(entry.winLine) && entry.winLine.every(isMoveOrNull))) &&
+ STATUS_VALUES.has(entry.status) &&
+ (entry.winner === null || entry.winner === BLACK || entry.winner === WHITE) &&
+ Number.isInteger(entry.moveCount) &&
+ entry.moveCount >= 0 &&
+ entry.moveCount <= BOARD_SIZE * BOARD_SIZE
+ );
+}
+
+/**
+ * 校验完整对局快照。
+ * @param value 待校验数据。
+ * @returns 数据是否为合法对局。
+ */
+function isGameState(value: unknown): value is GameState {
+ if (!value || typeof value !== "object") return false;
+ const game = value as GameState;
+ const occupied = isBoard(game.board) ? game.board.filter((stone) => stone !== 0).length : -1;
+ return (
+ isBoard(game.board) &&
+ (game.currentPlayer === BLACK || game.currentPlayer === WHITE) &&
+ STATUS_VALUES.has(game.status) &&
+ isMoveOrNull(game.lastMove) &&
+ (game.winLine === null || (Array.isArray(game.winLine) && game.winLine.every(isMoveOrNull))) &&
+ (game.winner === null || game.winner === BLACK || game.winner === WHITE) &&
+ Number.isInteger(game.moveCount) &&
+ game.moveCount === occupied &&
+ Array.isArray(game.history) &&
+ game.history.length === game.moveCount &&
+ game.history.every(isHistoryEntry)
+ );
+}
+
+/**
+ * 读取已保存对局,损坏数据自动回退到新棋局。
+ * @returns 可安全恢复的对局状态。
+ */
+export function loadGame(): GameState {
+ const value = readPersistent(GAME_KEY, null);
+ return isGameState(value) ? value : createGame();
+}
+
+/**
+ * 保存当前对局。
+ * @param game 当前完整对局。
+ * @returns 无返回值。
+ */
+export function saveGame(game: GameState): void {
+ writePersistent(GAME_KEY, game);
+}
+
+/**
+ * 读取 AI 难度。
+ * @returns 有效的 AI 难度。
+ */
+export function loadDifficulty(): Difficulty {
+ const value = readPersistent(DIFFICULTY_KEY, "medium");
+ return DIFFICULTY_VALUES.has(value as Difficulty) ? (value as Difficulty) : "medium";
+}
+
+/**
+ * 保存 AI 难度。
+ * @param difficulty AI 难度。
+ * @returns 无返回值。
+ */
+export function saveDifficulty(difficulty: Difficulty): void {
+ writePersistent(DIFFICULTY_KEY, difficulty);
+}
+
+/**
+ * 读取玩家执子颜色。
+ * @returns 黑棋或白棋。
+ */
+export function loadHumanColor(): Player {
+ const value = readPersistent(HUMAN_COLOR_KEY, BLACK);
+ return value === WHITE ? WHITE : BLACK;
+}
+
+/**
+ * 保存玩家执子颜色。
+ * @param player 玩家颜色。
+ * @returns 无返回值。
+ */
+export function saveHumanColor(player: Player): void {
+ writePersistent(HUMAN_COLOR_KEY, player);
+}
+
+/**
+ * 读取当前对局计时。
+ * @returns 限制在合理范围内的毫秒数。
+ */
+export function loadElapsedMs(): number {
+ const value = readPersistent(ELAPSED_KEY, 0);
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
+ ? Math.min(value, 7 * 24 * 60 * 60 * 1000)
+ : 0;
+}
+
+/**
+ * 保存当前对局计时。
+ * @param elapsedMs 已用毫秒数。
+ * @returns 无返回值。
+ */
+export function saveElapsedMs(elapsedMs: number): void {
+ writePersistent(ELAPSED_KEY, Math.max(0, Math.floor(elapsedMs)));
+}
diff --git a/plugins/gomoku-3d-ztools/src/lib/gomoku/replay.ts b/plugins/gomoku-3d-ztools/src/lib/gomoku/replay.ts
new file mode 100644
index 000000000..f4fd558af
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/lib/gomoku/replay.ts
@@ -0,0 +1,84 @@
+import {
+ Board,
+ Move,
+ Player,
+ GameStatus,
+ BLACK,
+ BOARD_SIZE,
+ createBoard,
+} from "./types";
+import type { GameState, HistoryEntry } from "./engine";
+
+/** One derived move of the finished/ongoing game, 1-based `index`. */
+export interface ReplayMove {
+ index: number;
+ x: number;
+ y: number;
+ player: Player;
+ /** "黑" | "白" — colour label, side (玩家/电脑) is mapped by the UI. */
+ label: string;
+}
+
+/**
+ * Frozen copy of the fields replay needs. Taken once on enterReplay so the
+ * replay view is immune to any live-game changes while it is open.
+ */
+export interface ReplaySnapshot {
+ history: HistoryEntry[];
+ board: Board;
+ moveCount: number;
+ status: GameStatus;
+ winner: Player | null;
+ winLine: Move[] | null;
+}
+
+/** Structural subset shared by GameState and ReplaySnapshot. */
+export type ReplaySource = Pick;
+
+export function snapshotGame(game: GameState): ReplaySnapshot {
+ return {
+ history: game.history.slice(),
+ board: game.board.slice(),
+ moveCount: game.moveCount,
+ status: game.status,
+ winner: game.winner,
+ winLine: game.winLine ? game.winLine.map((m) => ({ ...m })) : null,
+ };
+}
+
+/**
+ * Derive the ordered move list (length = moveCount) from the history.
+ * `history[k]` is the snapshot taken BEFORE move k+1, i.e. it contains k
+ * stones; move j's coordinate is the single cell that differs between the
+ * board before move j and the board after move j.
+ */
+export function deriveMoves(game: ReplaySource): ReplayMove[] {
+ const N = game.moveCount;
+ const moves: ReplayMove[] = [];
+ for (let j = 1; j <= N; j++) {
+ const prev = j - 1 === 0 ? createBoard() : game.history[j - 1].board;
+ const cur = j < N ? game.history[j].board : game.board;
+ let x = -1;
+ let y = -1;
+ for (let i = 0; i < cur.length; i++) {
+ if (cur[i] !== prev[i]) {
+ x = i % BOARD_SIZE;
+ y = Math.floor(i / BOARD_SIZE);
+ break;
+ }
+ }
+ const player = game.history[j - 1].currentPlayer;
+ moves.push({ index: j, x, y, player, label: player === BLACK ? "黑" : "白" });
+ }
+ return moves;
+}
+
+/**
+ * Board after `step` moves (step ∈ [0, N]). Uses the recorded history
+ * directly — no move-by-move re-simulation.
+ */
+export function boardAtStep(game: ReplaySource, step: number): Board {
+ if (step <= 0) return createBoard();
+ if (step >= game.moveCount) return game.board;
+ return game.history[step].board;
+}
diff --git a/plugins/gomoku-3d-ztools/src/lib/gomoku/stats.ts b/plugins/gomoku-3d-ztools/src/lib/gomoku/stats.ts
new file mode 100644
index 000000000..6456f52ae
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/lib/gomoku/stats.ts
@@ -0,0 +1,258 @@
+import type { Difficulty } from "./ai";
+import { readPersistent, writePersistent } from "@/lib/pluginHost";
+
+export interface GameStats {
+ games: number;
+ wins: number;
+ losses: number;
+ draws: number;
+ /** current consecutive-win streak */
+ streak: number;
+ /** best consecutive-win streak ever */
+ bestStreak: number;
+ /** fewest moves in a win, null if never won */
+ fastestWinMoves: number | null;
+ /** whether the player has ever beaten the hard AI */
+ beatHard: boolean;
+ /** whether the player has ever beaten the master AI */
+ beatMaster?: boolean;
+}
+
+export type AchievementId =
+ | "first_win"
+ | "streak3"
+ | "streak5"
+ | "speed"
+ | "beat_hard"
+ | "draw_master"
+ | "master_win";
+
+export interface AchievementDef {
+ id: AchievementId;
+ title: string;
+ desc: string;
+ /** lucide-react icon name, resolved in the UI layer */
+ icon: string;
+ tone:
+ | "orange"
+ | "amber"
+ | "rose"
+ | "violet"
+ | "emerald"
+ | "sky";
+}
+
+export const ACHIEVEMENTS: AchievementDef[] = [
+ {
+ id: "first_win",
+ title: "初露锋芒",
+ desc: "赢下你的第一盘对局",
+ icon: "Sparkles",
+ tone: "amber",
+ },
+ {
+ id: "streak3",
+ title: "小有斩获",
+ desc: "连续获胜 3 局",
+ icon: "Flame",
+ tone: "orange",
+ },
+ {
+ id: "streak5",
+ title: "五连胜王",
+ desc: "连续获胜 5 局",
+ icon: "Crown",
+ tone: "rose",
+ },
+ {
+ id: "speed",
+ title: "雷霆一击",
+ desc: "在 24 手之内取胜",
+ icon: "Zap",
+ tone: "sky",
+ },
+ {
+ id: "beat_hard",
+ title: "屠龙者",
+ desc: "在困难难度下击败 AI",
+ icon: "Sword",
+ tone: "violet",
+ },
+ {
+ id: "draw_master",
+ title: "棋逢对手",
+ desc: "下成一盘和棋",
+ icon: "Handshake",
+ tone: "emerald",
+ },
+ {
+ id: "master_win",
+ title: "无冕之王",
+ desc: "在大师难度下击败 AI",
+ icon: "Crown",
+ tone: "rose",
+ },
+];
+
+const STORAGE_KEY = "game-stats";
+
+export function emptyStats(): GameStats {
+ return {
+ games: 0,
+ wins: 0,
+ losses: 0,
+ draws: 0,
+ streak: 0,
+ bestStreak: 0,
+ fastestWinMoves: null,
+ beatHard: false,
+ beatMaster: false,
+ };
+}
+
+export function loadStats(): GameStats {
+ const value = readPersistent(STORAGE_KEY, null);
+ if (!value || typeof value !== "object") return emptyStats();
+ const stats = { ...emptyStats(), ...(value as Partial) };
+ return {
+ games: Math.max(0, Math.floor(Number(stats.games) || 0)),
+ wins: Math.max(0, Math.floor(Number(stats.wins) || 0)),
+ losses: Math.max(0, Math.floor(Number(stats.losses) || 0)),
+ draws: Math.max(0, Math.floor(Number(stats.draws) || 0)),
+ streak: Math.max(0, Math.floor(Number(stats.streak) || 0)),
+ bestStreak: Math.max(0, Math.floor(Number(stats.bestStreak) || 0)),
+ fastestWinMoves:
+ stats.fastestWinMoves === null
+ ? null
+ : Math.max(1, Math.floor(Number(stats.fastestWinMoves) || 1)),
+ beatHard: stats.beatHard === true,
+ beatMaster: stats.beatMaster === true,
+ };
+}
+
+export function saveStats(stats: GameStats): void {
+ writePersistent(STORAGE_KEY, stats);
+}
+
+export interface GameResult {
+ outcome: "win" | "loss" | "draw";
+ moves: number;
+ difficulty: Difficulty;
+}
+
+function earned(id: AchievementId, s: GameStats): boolean {
+ switch (id) {
+ case "first_win":
+ return s.wins >= 1;
+ case "streak3":
+ return s.bestStreak >= 3;
+ case "streak5":
+ return s.bestStreak >= 5;
+ case "speed":
+ return s.fastestWinMoves !== null && s.fastestWinMoves <= 24;
+ case "beat_hard":
+ return s.beatHard;
+ case "draw_master":
+ return s.draws >= 1;
+ case "master_win":
+ return s.beatMaster === true;
+ }
+}
+
+/** A 0..1 fill used to render a subtle progress ring on locked badges. */
+export function achievementProgress(id: AchievementId, s: GameStats): number {
+ switch (id) {
+ case "first_win":
+ return Math.min(1, s.wins);
+ case "streak3":
+ return Math.min(1, s.bestStreak / 3);
+ case "streak5":
+ return Math.min(1, s.bestStreak / 5);
+ case "speed":
+ return s.fastestWinMoves !== null && s.fastestWinMoves <= 24 ? 1 : 0;
+ case "beat_hard":
+ return s.beatHard ? 1 : 0;
+ case "draw_master":
+ return Math.min(1, s.draws);
+ case "master_win":
+ return s.beatMaster ? 1 : 0;
+ }
+}
+
+export function isEarned(id: AchievementId, s: GameStats): boolean {
+ return earned(id, s);
+}
+
+/** Apply a finished game to the stats, returning the new stats plus any
+ * achievements unlocked *by this game* (for celebration toasts). */
+export function recordResult(
+ prev: GameStats,
+ r: GameResult
+): { stats: GameStats; unlocked: AchievementDef[] } {
+ const next: GameStats = { ...prev, games: prev.games + 1 };
+ if (r.outcome === "win") {
+ next.wins += 1;
+ next.streak = prev.streak + 1;
+ next.bestStreak = Math.max(prev.bestStreak, next.streak);
+ next.fastestWinMoves =
+ prev.fastestWinMoves === null
+ ? r.moves
+ : Math.min(prev.fastestWinMoves, r.moves);
+ if (r.difficulty === "hard") next.beatHard = true;
+ if (r.difficulty === "master") next.beatMaster = true;
+ } else if (r.outcome === "loss") {
+ next.losses += 1;
+ next.streak = 0;
+ } else {
+ next.draws += 1;
+ next.streak = 0;
+ }
+
+ const unlocked: AchievementDef[] = [];
+ for (const a of ACHIEVEMENTS) {
+ if (!earned(a.id, prev) && earned(a.id, next)) unlocked.push(a);
+ }
+ return { stats: next, unlocked };
+}
+
+export const TONE_CLASSES: Record<
+ AchievementDef["tone"],
+ { ring: string; bg: string; text: string; glow: string }
+> = {
+ orange: {
+ ring: "ring-orange-300",
+ bg: "bg-orange-100",
+ text: "text-orange-600",
+ glow: "shadow-[0_8px_24px_-8px_rgba(234,88,12,0.6)]",
+ },
+ amber: {
+ ring: "ring-amber-300",
+ bg: "bg-amber-100",
+ text: "text-amber-600",
+ glow: "shadow-[0_8px_24px_-8px_rgba(217,119,6,0.55)]",
+ },
+ rose: {
+ ring: "ring-rose-300",
+ bg: "bg-rose-100",
+ text: "text-rose-600",
+ glow: "shadow-[0_8px_24px_-8px_rgba(225,29,72,0.55)]",
+ },
+ violet: {
+ ring: "ring-violet-300",
+ bg: "bg-violet-100",
+ text: "text-violet-600",
+ glow: "shadow-[0_8px_24px_-8px_rgba(139,92,246,0.55)]",
+ },
+ emerald: {
+ ring: "ring-emerald-300",
+ bg: "bg-emerald-100",
+ text: "text-emerald-600",
+ glow: "shadow-[0_8px_24px_-8px_rgba(16,185,129,0.55)]",
+ },
+ sky: {
+ ring: "ring-sky-300",
+ bg: "bg-sky-100",
+ text: "text-sky-600",
+ glow: "shadow-[0_8px_24px_-8px_rgba(14,165,233,0.55)]",
+ },
+};
diff --git a/plugins/gomoku-3d-ztools/src/lib/gomoku/types.ts b/plugins/gomoku-3d-ztools/src/lib/gomoku/types.ts
new file mode 100644
index 000000000..4eb76ba78
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/lib/gomoku/types.ts
@@ -0,0 +1,161 @@
+export const BOARD_SIZE = 15;
+
+/** 0 = empty, 1 = black (player), 2 = white (AI) */
+export type Stone = 0 | 1 | 2;
+export type Player = 1 | 2;
+
+export interface Move {
+ x: number;
+ y: number;
+}
+
+export type GameStatus =
+ | "playing"
+ | "black_win"
+ | "white_win"
+ | "draw";
+
+export const EMPTY: Stone = 0;
+export const BLACK: Player = 1;
+export const WHITE: Player = 2;
+
+export function opponent(p: Player): Player {
+ return (p === BLACK ? WHITE : BLACK) as Player;
+}
+
+/** Flat board: index = y * BOARD_SIZE + x */
+export type Board = Stone[];
+
+export function createBoard(): Board {
+ return new Array(BOARD_SIZE * BOARD_SIZE).fill(EMPTY);
+}
+
+export function idx(x: number, y: number): number {
+ return y * BOARD_SIZE + x;
+}
+
+export function inBounds(x: number, y: number): boolean {
+ return x >= 0 && x < BOARD_SIZE && y >= 0 && y < BOARD_SIZE;
+}
+
+export function getStone(board: Board, x: number, y: number): Stone {
+ return board[idx(x, y)];
+}
+
+/** True if `move` is inside the board and currently empty. Reused by the
+ * engine and the UI layer to guard against illegal placements. */
+export function validateMove(board: Board, move: Move): boolean {
+ return inBounds(move.x, move.y) && board[idx(move.x, move.y)] === EMPTY;
+}
+
+export function cloneBoard(board: Board): Board {
+ return board.slice();
+}
+
+const DIRS: ReadonlyArray<[number, number]> = [
+ [1, 0],
+ [0, 1],
+ [1, 1],
+ [1, -1],
+];
+
+/**
+ * Check whether placing `player` at (x, y) produces a win.
+ * Call AFTER the stone has been written to the board.
+ */
+export function checkWinFrom(
+ board: Board,
+ x: number,
+ y: number,
+ player: Player
+): boolean {
+ for (const [dx, dy] of DIRS) {
+ let count = 1;
+ // forward
+ let nx = x + dx;
+ let ny = y + dy;
+ while (inBounds(nx, ny) && board[idx(nx, ny)] === player) {
+ count++;
+ nx += dx;
+ ny += dy;
+ }
+ // backward
+ nx = x - dx;
+ ny = y - dy;
+ while (inBounds(nx, ny) && board[idx(nx, ny)] === player) {
+ count++;
+ nx -= dx;
+ ny -= dy;
+ }
+ if (count >= 5) return true;
+ }
+ return false;
+}
+
+export function isFull(board: Board): boolean {
+ return board.every((c) => c !== EMPTY);
+}
+
+/**
+ * Return the consecutive run (>=5) that makes `player` win at (x, y).
+ * Used to highlight the winning line. Returns [] if not a win.
+ */
+export function getWinningLine(
+ board: Board,
+ x: number,
+ y: number,
+ player: Player
+): Move[] {
+ for (const [dx, dy] of DIRS) {
+ const cells: Move[] = [{ x, y }];
+ let nx = x + dx;
+ let ny = y + dy;
+ while (inBounds(nx, ny) && board[idx(nx, ny)] === player) {
+ cells.push({ x: nx, y: ny });
+ nx += dx;
+ ny += dy;
+ }
+ nx = x - dx;
+ ny = y - dy;
+ while (inBounds(nx, ny) && board[idx(nx, ny)] === player) {
+ cells.unshift({ x: nx, y: ny });
+ nx -= dx;
+ ny -= dy;
+ }
+ if (cells.length >= 5) return cells;
+ }
+ return [];
+}
+
+/** All empty cells adjacent (within radius) to an existing stone. */
+export function getCandidateMoves(
+ board: Board,
+ radius = 2
+): Move[] {
+ const seen = new Set();
+ const moves: Move[] = [];
+ let hasStone = false;
+ for (let y = 0; y < BOARD_SIZE; y++) {
+ for (let x = 0; x < BOARD_SIZE; x++) {
+ if (board[idx(x, y)] === EMPTY) continue;
+ hasStone = true;
+ for (let dy = -radius; dy <= radius; dy++) {
+ for (let dx = -radius; dx <= radius; dx++) {
+ const nx = x + dx;
+ const ny = y + dy;
+ if (!inBounds(nx, ny)) continue;
+ if (board[idx(nx, ny)] !== EMPTY) continue;
+ const key = idx(nx, ny);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ moves.push({ x: nx, y: ny });
+ }
+ }
+ }
+ }
+ if (!hasStone) {
+ const c = Math.floor(BOARD_SIZE / 2);
+ return [{ x: c, y: c }];
+ }
+ return moves;
+}
diff --git a/plugins/gomoku-3d-ztools/src/lib/pluginHost.ts b/plugins/gomoku-3d-ztools/src/lib/pluginHost.ts
new file mode 100644
index 000000000..15466f6a0
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/lib/pluginHost.ts
@@ -0,0 +1,106 @@
+export const PLUGIN_ACTIVITY_EVENT = "gomoku:plugin-activity";
+
+const FALLBACK_PREFIX = "gomoku-ztools:";
+
+interface ZToolsStorage {
+ getItem(key: string): unknown;
+ setItem(key: string, value: unknown): void;
+ removeItem?(key: string): void;
+}
+
+interface ZToolsApi {
+ dbStorage?: ZToolsStorage;
+ onPluginEnter?(callback: (action: { code?: string }) => void): void;
+ onPluginOut?(callback: () => void): void;
+}
+
+interface GomokuBridge {
+ saveTextFile(input: {
+ format: "sgf" | "json" | "txt";
+ suggestedName: string;
+ text: string;
+ }): { cancelled: boolean; path?: string } | Promise<{ cancelled: boolean; path?: string }>;
+}
+
+declare global {
+ interface Window {
+ ztools?: ZToolsApi;
+ gomokuBridge?: GomokuBridge;
+ __gomokuPluginActive?: boolean;
+ }
+}
+
+/**
+ * 从 ZTools 插件存储读取设置,浏览器预览时回退到 localStorage。
+ * @param key 设置键。
+ * @param fallback 设置不存在或损坏时的默认值。
+ * @returns 已保存且可解析的设置值。
+ */
+export function readPersistent(key: string, fallback: T): T {
+ try {
+ const hostValue = window.ztools?.dbStorage?.getItem(key);
+ if (hostValue !== undefined && hostValue !== null) return hostValue as T;
+ const raw = window.localStorage.getItem(FALLBACK_PREFIX + key);
+ return raw === null ? fallback : (JSON.parse(raw) as T);
+ } catch {
+ return fallback;
+ }
+}
+
+/**
+ * 将设置写入 ZTools 插件存储,浏览器预览时同时写入 localStorage。
+ * @param key 设置键。
+ * @param value 可序列化的设置值。
+ * @returns 无返回值。
+ */
+export function writePersistent(key: string, value: T): void {
+ try {
+ if (window.ztools?.dbStorage) {
+ window.ztools.dbStorage.setItem(key, value);
+ return;
+ }
+ window.localStorage.setItem(FALLBACK_PREFIX + key, JSON.stringify(value));
+ } catch {
+ // 存储不可用不应阻断游戏。
+ }
+}
+
+/**
+ * 删除持久化设置。
+ * @param key 设置键。
+ * @returns 无返回值。
+ */
+export function removePersistent(key: string): void {
+ try {
+ if (window.ztools?.dbStorage?.removeItem) {
+ window.ztools.dbStorage.removeItem(key);
+ return;
+ }
+ window.localStorage.removeItem(FALLBACK_PREFIX + key);
+ } catch {
+ // 存储不可用不应阻断游戏。
+ }
+}
+
+/**
+ * 更新插件激活状态并广播给游戏逻辑。
+ * @param active 插件当前是否处于前台。
+ * @returns 无返回值。
+ */
+function publishActivity(active: boolean): void {
+ window.__gomokuPluginActive = active;
+ window.dispatchEvent(new CustomEvent(PLUGIN_ACTIVITY_EVENT, { detail: active }));
+}
+
+/**
+ * 注册 ZTools 进入/退出生命周期,并提供浏览器可见性降级。
+ * @returns 无返回值。
+ */
+export function setupPluginLifecycle(): void {
+ publishActivity(!document.hidden);
+ window.ztools?.onPluginEnter?.((action) => {
+ if (!action.code || action.code === "gomoku-3d") publishActivity(true);
+ });
+ window.ztools?.onPluginOut?.(() => publishActivity(false));
+ document.addEventListener("visibilitychange", () => publishActivity(!document.hidden));
+}
diff --git a/plugins/gomoku-3d-ztools/src/lib/utils.ts b/plugins/gomoku-3d-ztools/src/lib/utils.ts
new file mode 100644
index 000000000..a5ef19350
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/plugins/gomoku-3d-ztools/src/main.tsx b/plugins/gomoku-3d-ztools/src/main.tsx
new file mode 100644
index 000000000..e81aa6bf9
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/main.tsx
@@ -0,0 +1,14 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App";
+import { setupPluginLifecycle } from "@/lib/pluginHost";
+import "./i18n";
+import "./index.css";
+
+setupPluginLifecycle();
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/plugins/gomoku-3d-ztools/src/vite-env.d.ts b/plugins/gomoku-3d-ztools/src/vite-env.d.ts
new file mode 100644
index 000000000..11f02fe2a
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/plugins/gomoku-3d-ztools/tailwind.config.js b/plugins/gomoku-3d-ztools/tailwind.config.js
new file mode 100644
index 000000000..4e602c8ad
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/tailwind.config.js
@@ -0,0 +1,51 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ darkMode: ["class"],
+ content: ["./index.html", "./src/**/*.{ts,tsx}"],
+ theme: {
+ container: {
+ center: true,
+ padding: "2rem",
+ screens: { "2xl": "1400px" },
+ },
+ extend: {
+ colors: {
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "var(--plugin-primary-color, hsl(var(--ring)))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ primary: {
+ DEFAULT: "var(--plugin-primary-color, hsl(var(--primary)))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/plugins/gomoku-3d-ztools/tests/e2e/plugin.spec.js b/plugins/gomoku-3d-ztools/tests/e2e/plugin.spec.js
new file mode 100644
index 000000000..b6c82050e
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/tests/e2e/plugin.spec.js
@@ -0,0 +1,328 @@
+import { expect, test, _electron as electron } from '@playwright/test'
+import fs from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+
+const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
+const sourcePluginRoot = path.join(projectRoot, 'src-ztools')
+
+/**
+ * 在设置插件的 WebContentsView 中执行受控脚本。
+ * @param {import('@playwright/test').ElectronApplication} electronApp 隔离的 Electron 应用。
+ * @param {string} source 要执行的页面脚本。
+ * @returns {Promise} 页面脚本执行结果。
+ */
+async function executeInSettings(electronApp, source) {
+ return electronApp.evaluate(async ({ webContents }, script) => {
+ const contents = webContents
+ .getAllWebContents()
+ .find((item) => item.getURL().includes('internal-plugins/setting/index.html'))
+ if (!contents) throw new Error('未找到设置插件 WebContentsView')
+ return contents.executeJavaScript(script)
+ }, source)
+}
+
+/**
+ * 读取设置页正文以等待其完成加载。
+ * @param {import('@playwright/test').ElectronApplication} electronApp 隔离的 Electron 应用。
+ * @returns {Promise} 设置页正文。
+ */
+async function readSettingsText(electronApp) {
+ return electronApp.evaluate(async ({ webContents }) => {
+ const contents = webContents
+ .getAllWebContents()
+ .find((item) => item.getURL().includes('internal-plugins/setting/index.html'))
+ if (!contents || contents.isLoading()) return ''
+ return contents.executeJavaScript('document.body?.innerText || ""')
+ })
+}
+
+/**
+ * 在五子棋插件页面执行脚本。
+ * @param {import('@playwright/test').ElectronApplication} electronApp 隔离的 Electron 应用。
+ * @param {string} expectedUrl 插件生产页面地址。
+ * @param {string} source 要执行的脚本。
+ * @returns {Promise} 页面脚本结果。
+ */
+async function executeInPlugin(electronApp, expectedUrl, source) {
+ return electronApp.evaluate(async ({ webContents }, args) => {
+ const candidates = webContents.getAllWebContents().filter((item) => item.getURL() === args.expectedUrl)
+ const measured = await Promise.all(candidates.map(async (contents) => ({
+ contents,
+ area: await contents.executeJavaScript(`Math.max(0, ...[...document.querySelectorAll('canvas')].map((canvas) => canvas.clientWidth * canvas.clientHeight))`).catch(() => 0),
+ })))
+ const contents = measured.sort((a, b) => b.area - a.area)[0]?.contents
+ if (!contents) throw new Error('未找到五子棋插件 WebContentsView')
+ return contents.executeJavaScript(args.source)
+ }, { expectedUrl, source })
+}
+
+/**
+ * 通过 Electron 原生输入通道点击可见棋盘中心。
+ * @param {import('@playwright/test').ElectronApplication} electronApp 隔离的 Electron 应用。
+ * @param {string} expectedUrl 插件生产页面地址。
+ * @returns {Promise} 是否找到棋盘并发送了点击。
+ */
+async function clickPluginBoard(electronApp, expectedUrl) {
+ return electronApp.evaluate(async ({ webContents }, targetUrl) => {
+ const candidates = webContents.getAllWebContents().filter((item) => item.getURL() === targetUrl)
+ const measured = await Promise.all(candidates.map(async (contents) => ({
+ contents,
+ area: await contents.executeJavaScript(`Math.max(0, ...[...document.querySelectorAll('canvas')].map((canvas) => canvas.getBoundingClientRect().width * canvas.getBoundingClientRect().height))`).catch(() => 0),
+ })))
+ const contents = measured.sort((a, b) => b.area - a.area)[0]?.contents
+ if (!contents) return false
+ const point = await contents.executeJavaScript(`(() => {
+ const canvas = [...document.querySelectorAll('canvas')].sort((a, b) => {
+ const ar = a.getBoundingClientRect()
+ const br = b.getBoundingClientRect()
+ return br.width * br.height - ar.width * ar.height
+ })[0]
+ if (!canvas) return null
+ const rect = canvas.getBoundingClientRect()
+ return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }
+ })()`)
+ if (!point) return false
+ contents.sendInputEvent({ type: 'mouseMove', x: point.x, y: point.y })
+ contents.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 })
+ contents.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 })
+ return true
+ }, expectedUrl)
+}
+
+/**
+ * 截取插件画面并统计非背景像素,避免只验证 DOM 而遗漏 WebGL 白屏。
+ * @param {import('@playwright/test').ElectronApplication} electronApp 隔离的 Electron 应用。
+ * @param {string} expectedUrl 插件生产页面地址。
+ * @returns {Promise<{ready: boolean, text: string, png: string, width: number, height: number, nonBackgroundPixels: number, boardRenderedPixels: number, canvasWidth: number, canvasHeight: number, webglReady: boolean, drawingBufferWidth: number, drawingBufferHeight: number}>} 插件状态。
+ */
+async function inspectPluginView(electronApp, expectedUrl) {
+ return electronApp.evaluate(async ({ webContents }, targetUrl) => {
+ const candidates = webContents.getAllWebContents().filter((item) => item.getURL() === targetUrl)
+ const measured = await Promise.all(candidates.map(async (contents) => ({
+ contents,
+ area: await contents.executeJavaScript(`Math.max(0, ...[...document.querySelectorAll('canvas')].map((canvas) => canvas.clientWidth * canvas.clientHeight))`).catch(() => 0),
+ })))
+ const contents = measured.sort((a, b) => b.area - a.area)[0]?.contents
+ if (!contents || contents.isLoading()) {
+ return {
+ ready: false,
+ text: '',
+ png: '',
+ width: 0,
+ height: 0,
+ nonBackgroundPixels: 0,
+ boardRenderedPixels: 0,
+ canvasWidth: 0,
+ canvasHeight: 0,
+ webglReady: false,
+ drawingBufferWidth: 0,
+ drawingBufferHeight: 0,
+ }
+ }
+ const state = await contents.executeJavaScript(`(() => {
+ const canvases = [...document.querySelectorAll('canvas')]
+ const canvas = canvases.sort((a, b) =>
+ b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight
+ )[0]
+ const rect = canvas?.getBoundingClientRect()
+ const gl = canvas?.getContext('webgl2') || canvas?.getContext('webgl')
+ return {
+ text: document.body?.innerText || '',
+ canvasWidth: rect?.width || 0,
+ canvasHeight: rect?.height || 0,
+ canvasRect: rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null,
+ webglReady: !!gl,
+ drawingBufferWidth: gl?.drawingBufferWidth || 0,
+ drawingBufferHeight: gl?.drawingBufferHeight || 0,
+ }
+ })()`)
+ const image = await contents.capturePage()
+ const bitmap = image.toBitmap()
+ let nonBackgroundPixels = 0
+ for (let index = 0; index < bitmap.length; index += 4) {
+ const blue = bitmap[index]
+ const green = bitmap[index + 1]
+ const red = bitmap[index + 2]
+ const spread = Math.max(red, green, blue) - Math.min(red, green, blue)
+ if (spread > 8 || (red + green + blue) / 3 < 218) nonBackgroundPixels += 1
+ }
+ let boardRenderedPixels = 0
+ if (state.canvasRect && state.canvasRect.width > 0 && state.canvasRect.height > 0) {
+ const boardImage = await contents.capturePage({
+ x: Math.max(0, Math.floor(state.canvasRect.x)),
+ y: Math.max(0, Math.floor(state.canvasRect.y)),
+ width: Math.max(1, Math.floor(state.canvasRect.width)),
+ height: Math.max(1, Math.floor(state.canvasRect.height)),
+ })
+ const boardBitmap = boardImage.toBitmap()
+ for (let index = 0; index < boardBitmap.length; index += 4) {
+ const blue = boardBitmap[index]
+ const green = boardBitmap[index + 1]
+ const red = boardBitmap[index + 2]
+ const spread = Math.max(red, green, blue) - Math.min(red, green, blue)
+ if (spread > 14 || (red + green + blue) / 3 < 225) boardRenderedPixels += 1
+ }
+ }
+ return {
+ ready: true,
+ text: state.text,
+ png: image.toPNG().toString('base64'),
+ width: image.getSize().width,
+ height: image.getSize().height,
+ nonBackgroundPixels,
+ boardRenderedPixels,
+ canvasWidth: state.canvasWidth,
+ canvasHeight: state.canvasHeight,
+ webglReady: state.webglReady,
+ drawingBufferWidth: state.drawingBufferWidth,
+ drawingBufferHeight: state.drawingBufferHeight,
+ }
+ }, expectedUrl)
+}
+
+test('可在真实 ZTools 中安装、渲染 WebGL 棋盘并持久化视图设置', async ({}, testInfo) => {
+ const dataRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ztools-gomoku-e2e-'))
+ const legacyRoot = path.join(dataRoot, 'legacy')
+ const pluginRoot = path.join(dataRoot, 'plugin')
+ const pluginConfigPath = path.join(pluginRoot, 'plugin.json')
+ await fs.cp(sourcePluginRoot, pluginRoot, { recursive: true })
+ const manifest = JSON.parse(await fs.readFile(pluginConfigPath, 'utf8'))
+ delete manifest.development
+ await fs.writeFile(pluginConfigPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
+ const expectedUrl = pathToFileURL(path.resolve(pluginRoot, manifest.main)).href
+ const screenshotPath = testInfo.outputPath('gomoku-plugin.png')
+ let electronApp = null
+
+ await fs.mkdir(legacyRoot, { recursive: true })
+ try {
+ electronApp = await electron.launch({
+ executablePath:
+ process.env.ZTOOLS_E2E_EXECUTABLE_PATH
+ || '/Applications/ZTools.app/Contents/MacOS/ZTools',
+ args: [],
+ env: {
+ ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value)),
+ ZTOOLS_DATA_ROOT: dataRoot,
+ ZTOOLS_E2E: '1',
+ ZTOOLS_LEGACY_USER_DATA_PATH: legacyRoot,
+ },
+ })
+
+ const page = await electronApp.firstWindow()
+ const searchInput = page.locator('.search-input')
+ await expect(searchInput).toBeVisible()
+ await searchInput.fill('通用设置')
+ await page.locator('.app-item, .list-item').filter({ hasText: '通用设置' }).first().click()
+ await expect.poll(() => readSettingsText(electronApp), { timeout: 20_000 }).not.toBe('')
+
+ const imported = await executeInSettings(
+ electronApp,
+ `window.ztools.internal.importDevPlugin(${JSON.stringify(pluginConfigPath)})`,
+ )
+ expect(imported).toMatchObject({ success: true })
+ const installed = await executeInSettings(
+ electronApp,
+ `window.ztools.internal.installDevPlugin(${JSON.stringify(manifest.name)})`,
+ )
+ expect(installed).toMatchObject({ success: true })
+ const launched = await executeInSettings(
+ electronApp,
+ `window.ztools.internal.launch({path: ${JSON.stringify(pluginRoot)}, type: 'plugin', name: ${JSON.stringify(manifest.title)}, param: {payload: '', type: 'text', code: 'gomoku-3d'}})`,
+ )
+ expect(launched).toMatchObject({ success: true })
+
+ await expect.poll(
+ async () => (await inspectPluginView(electronApp, expectedUrl)).text,
+ { timeout: 25_000 },
+ ).toContain('五子棋')
+
+ await expect.poll(
+ async () => (await inspectPluginView(electronApp, expectedUrl)).canvasWidth,
+ { timeout: 20_000 },
+ ).toBeGreaterThan(250)
+ await expect.poll(
+ async () => (await inspectPluginView(electronApp, expectedUrl)).boardRenderedPixels,
+ { timeout: 20_000 },
+ ).toBeGreaterThan(20_000)
+ const inspection = await inspectPluginView(electronApp, expectedUrl)
+ expect(inspection.canvasWidth).toBeGreaterThan(250)
+ expect(inspection.canvasHeight).toBeGreaterThan(250)
+ expect(inspection.webglReady).toBe(true)
+ expect(inspection.drawingBufferWidth).toBeGreaterThan(250)
+ expect(inspection.drawingBufferHeight).toBeGreaterThan(250)
+ expect(inspection.nonBackgroundPixels).toBeGreaterThan(10_000)
+ expect(inspection.boardRenderedPixels).toBeGreaterThan(20_000)
+
+ const placed = await clickPluginBoard(electronApp, expectedUrl)
+ expect(placed).toBe(true)
+ await expect.poll(
+ () => executeInPlugin(
+ electronApp,
+ expectedUrl,
+ `Number((document.body?.innerText || '').match(/手数\\s*(\\d+)/)?.[1] || 0)`,
+ ),
+ { timeout: 10_000 },
+ ).toBeGreaterThan(0)
+
+ const switched = await executeInPlugin(
+ electronApp,
+ expectedUrl,
+ `(() => {
+ const button = [...document.querySelectorAll('button')].find((item) => item.textContent?.includes('2D 平面'))
+ if (!button) return false
+ button.click()
+ return true
+ })()`,
+ )
+ expect(switched).toBe(true)
+ await expect.poll(
+ () => executeInPlugin(
+ electronApp,
+ expectedUrl,
+ `[...document.querySelectorAll('button')].find((item) => item.textContent?.includes('2D 平面'))?.getAttribute('aria-pressed') || ''`,
+ ),
+ ).toBe('true')
+
+ await electronApp.evaluate(async ({ webContents }, targetUrl) => {
+ const candidates = webContents.getAllWebContents().filter((item) => item.getURL() === targetUrl)
+ const measured = await Promise.all(candidates.map(async (contents) => ({
+ contents,
+ area: await contents.executeJavaScript(`Math.max(0, ...[...document.querySelectorAll('canvas')].map((canvas) => canvas.clientWidth * canvas.clientHeight))`).catch(() => 0),
+ })))
+ const contents = measured.sort((a, b) => b.area - a.area)[0]?.contents
+ if (!contents) throw new Error('未找到插件页面')
+ await contents.reload()
+ }, expectedUrl)
+ await expect.poll(
+ () => executeInPlugin(
+ electronApp,
+ expectedUrl,
+ `[...document.querySelectorAll('button')].find((item) => item.textContent?.includes('2D 平面'))?.getAttribute('aria-pressed') || ''`,
+ ),
+ { timeout: 20_000 },
+ ).toBe('true')
+ await expect.poll(
+ () => executeInPlugin(
+ electronApp,
+ expectedUrl,
+ `Number((document.body?.innerText || '').match(/手数\\s*(\\d+)/)?.[1] || 0)`,
+ ),
+ { timeout: 20_000 },
+ ).toBeGreaterThan(0)
+
+ const bridgeType = await executeInPlugin(
+ electronApp,
+ expectedUrl,
+ `typeof window.gomokuBridge?.saveTextFile`,
+ )
+ expect(bridgeType).toBe('function')
+
+ await fs.writeFile(screenshotPath, Buffer.from(inspection.png, 'base64'))
+ await testInfo.attach('gomoku-plugin', { path: screenshotPath, contentType: 'image/png' })
+ } finally {
+ await electronApp?.close()
+ await fs.rm(dataRoot, { recursive: true, force: true })
+ }
+})
diff --git a/plugins/gomoku-3d-ztools/tsconfig.json b/plugins/gomoku-3d-ztools/tsconfig.json
new file mode 100644
index 000000000..c17377efb
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/plugins/gomoku-3d-ztools/vite.config.ts b/plugins/gomoku-3d-ztools/vite.config.ts
new file mode 100644
index 000000000..6e23bfeef
--- /dev/null
+++ b/plugins/gomoku-3d-ztools/vite.config.ts
@@ -0,0 +1,32 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const projectRoot = path.dirname(fileURLToPath(import.meta.url));
+
+export default defineConfig({
+ base: "./",
+ plugins: [react()],
+ resolve: {
+ alias: {
+ "@": path.resolve(projectRoot, "src"),
+ },
+ },
+ build: {
+ outDir: path.resolve(projectRoot, "src-ztools/dist"),
+ emptyOutDir: true,
+ chunkSizeWarningLimit: 1200,
+ rolldownOptions: {
+ output: {
+ manualChunks(id: string) {
+ if (!id.includes("node_modules")) return undefined;
+ if (id.includes("three")) return "three";
+ if (id.includes("@react-three")) return "r3f";
+ if (id.includes("react")) return "react-vendor";
+ return "vendor";
+ },
+ },
+ },
+ },
+});