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.** + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![React](https://img.shields.io/badge/React-18-61dafb.svg)](https://react.dev/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5-3178c6.svg)](https://www.typescriptlang.org/) +[![Vite](https://img.shields.io/badge/Vite-5-646cff.svg)](https://vitejs.dev/) +[![Three.js](https://img.shields.io/badge/Three.js-169-black.svg)](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 构建。** + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![React](https://img.shields.io/badge/React-18-61dafb.svg)](https://react.dev/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5-3178c6.svg)](https://www.typescriptlang.org/) +[![Vite](https://img.shields.io/badge/Vite-5-646cff.svg)](https://vitejs.dev/) +[![Three.js](https://img.shields.io/badge/Three.js-169-black.svg)](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 ( +
+ +
+ + +
+
+ + + ); +} + +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 / <html lang> 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: <Sparkles className="h-5 w-5" />, + 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 ( + <div className="relative isolate min-h-screen w-full overflow-hidden"> + {/* ---- aurora background with mouse parallax ---- */} + <div className="pointer-events-none fixed inset-0 z-0 overflow-hidden bg-gradient-to-b from-amber-50 via-orange-50/40 to-rose-50/30 dark:from-stone-950 dark:via-stone-900 dark:to-orange-950/70"> + <div + className="absolute -left-24 -top-24 h-[42rem] w-[42rem] rounded-full bg-orange-300/30 blur-3xl animate-aurora transition-transform duration-700 ease-out" + style={{ transform: `translate3d(${par.x * 38}px, ${par.y * 38}px, 0)` }} + /> + <div + className="absolute -right-32 top-1/3 h-[34rem] w-[34rem] rounded-full bg-amber-200/40 blur-3xl animate-aurora transition-transform duration-700 ease-out" + style={{ + animationDelay: "-6s", + transform: `translate3d(${par.x * -30}px, ${par.y * -30}px, 0)`, + }} + /> + <div + className="absolute bottom-[-10rem] left-1/3 h-[36rem] w-[36rem] rounded-full bg-rose-200/30 blur-3xl animate-aurora transition-transform duration-700 ease-out" + style={{ + animationDelay: "-12s", + transform: `translate3d(${par.x * 24}px, ${par.y * 24}px, 0)`, + }} + /> + </div> + + {/* bottom padding = mobile action bar height (~61px: py-2.5×2 + 40px + buttons + border) + breathing room, plus the iOS safe-area inset */} + <div className="relative mx-auto max-w-6xl px-4 py-6 pb-[calc(4.5rem_+_env(safe-area-inset-bottom))] sm:py-8 lg:pb-0"> + {/* ---- header ---- */} + <header className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> + <div className="flex items-center gap-3"> + <div className="relative flex h-12 w-12 animate-float items-center justify-center rounded-2xl bg-gradient-to-br from-orange-500 to-amber-400 text-xl font-black text-white shadow-[var(--shadow-glow)]"> + 五 + </div> + <div> + <h1 + onClick={triggerEgg} + className="cursor-pointer text-2xl font-bold tracking-tight text-stone-800 transition-transform hover:scale-[1.02] active:scale-95" + > + {t("header.title")}{" "} + <span className="text-gradient">{t("header.brand")}</span> + </h1> + <p className="mt-0.5 text-sm text-stone-500">{t("header.subtitle")}</p> + </div> + </div> + + <div className="flex flex-wrap items-center gap-2.5"> + <Badge + variant={statusText.tone} + className="w-fit gap-1.5 px-3 py-1.5 text-sm shadow-[var(--shadow-soft)]" + > + {thinking ? ( + <span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-stone-400 border-t-transparent" /> + ) : status === "playing" ? ( + <Circle + className={cn( + "h-3.5 w-3.5 fill-current", + currentPlayer === BLACK ? "text-stone-800" : "text-white" + )} + /> + ) : null} + {statusText.label} + </Badge> + + <LanguageSwitcher /> + + <div className="flex items-center gap-2"> + {muted ? ( + <VolumeX className="h-4 w-4 text-stone-400" /> + ) : ( + <Volume2 className="h-4 w-4 text-orange-500" /> + )} + <Label htmlFor="mute-switch" className="text-sm text-stone-600"> + {t("header.sound")} + </Label> + <Switch + id="mute-switch" + checked={!muted} + onCheckedChange={() => toggleMute()} + aria-label={t("header.soundToggle")} + /> + </div> + </div> + </header> + + <div className="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_340px] lg:gap-6"> + {/* ---- board ---- */} + {/* Keep the board prominent in the embedded ZTools view while leaving + enough room for the header and fixed mobile action bar. */} + <Card className="mx-auto w-full max-w-[min(94vw,640px,calc(100svh_-_150px))] overflow-hidden p-0 shadow-[var(--shadow-card)] lg:max-w-none"> + <div + className={cn( + "relative aspect-square w-full transition-all duration-700", + boardMounted ? "opacity-100 scale-100" : "opacity-0 scale-95" + )} + > + <GameBoard + board={displayBoard} + currentPlayer={currentPlayer} + interactive={displayInteractive} + lastMove={displayLast} + winLine={displayWin} + onHover={setHover} + onPlace={handlePlace} + celebrate={isReplay ? false : celebrate} + viewMode={viewMode} + thinking={isReplay ? false : thinking} + drawn={isReplay ? replay.display.drawn : status === "draw"} + /> + + {/* AI thinking overlay: shimmer sweep + scanning + dots */} + {thinking && ( + <div className="pointer-events-none absolute inset-0 z-10 flex flex-col items-center justify-end gap-3 overflow-hidden rounded-2xl pb-6"> + <div className="absolute inset-0 bg-white/5" /> + <div className="absolute inset-y-0 -left-1/3 w-1/3 skew-x-12 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-shimmer" /> + <div className="flex items-center gap-2 rounded-full border border-white/60 bg-white/85 px-4 py-2 text-sm font-medium text-stone-700 shadow-[var(--shadow-soft)] backdrop-blur-md"> + <span className="h-4 w-4 animate-spin rounded-full border-2 border-orange-400 border-t-transparent" /> + {t("board.thinking")} + <span className="flex gap-1"> + {[0, 1, 2].map((i) => ( + <i + key={i} + className="h-1.5 w-1.5 rounded-full bg-orange-400 animate-[bounce-dot_1s_infinite]" + style={{ animationDelay: `${i * 160}ms` }} + /> + ))} + </span> + </div> + </div> + )} + + {/* replay-mode badge + current move info (aria-live) */} + {isReplay && ( + <div + aria-live="polite" + className="pointer-events-none absolute left-3 top-3 z-10" + > + <div className="flex items-center gap-2 rounded-full border border-white/60 bg-black/70 px-3 py-1.5 text-xs font-medium text-white shadow-[var(--shadow-soft)] backdrop-blur-md"> + <Film className="h-3.5 w-3.5" /> + {t("board.replayMode")} + <span className="tabular-nums"> + {replay.currentStep} / {replay.N} + </span> + </div> + </div> + )} + + {/* game-over overlay (hidden while replaying mid-game states) */} + {gameResult && ( + <GameOverOverlay + open={showOverlay} + result={gameResult} + canReplay={moveCount > 0 && !thinking} + onPlayAgain={restart} + onReplay={() => { + setOverlayClosed(true); + replay.enterReplay(); + }} + onDismiss={() => setOverlayClosed(true)} + /> + )} + </div> + + {/* ---- board status bar ---- */} + <div className="flex items-center justify-between gap-3 border-t border-stone-200/70 px-4 py-2.5 text-sm"> + <div className="flex items-center gap-2 text-stone-600"> + <Circle + className={cn( + "h-3.5 w-3.5 fill-current", + currentPlayer === BLACK ? "text-stone-800" : "text-white" + )} + /> + <span className="font-medium"> + {isReplay + ? t("board.replayReadonly") + : interactive + ? t("board.yourTurn") + : thinking + ? t("board.thinking") + : status === "playing" + ? t("board.aiTurn") + : ""} + </span> + </div> + <div className="flex items-center gap-3 text-stone-500"> + <span> + {t("board.moves")}{" "} + <b className="text-stone-800">{moveCount}</b> + </span> + <span> + {t("board.time")}{" "} + <b className="tabular-nums text-stone-800">{mmss}</b> + </span> + <span + title={t("board.hint")} + className="cursor-help text-stone-400" + > + ? + </span> + </div> + </div> + </Card> + + {/* ---- side panel ---- */} + <aside className="flex flex-col gap-4"> + {/* difficulty */} + <Card + interactive + className={cn( + "glass", + isReplay && "opacity-50 pointer-events-none" + )} + > + <CardHeader className="p-5 pb-3"> + <CardTitle className="text-base">{t("difficulty.title")}</CardTitle> + <CardDescription className="text-xs"> + {t("difficulty.desc")} + </CardDescription> + </CardHeader> + <CardContent className="p-5 pt-0"> + <div + role="radiogroup" + aria-label={t("difficulty.title")} + className="grid grid-cols-2 gap-2" + > + {DIFFICULTIES.map((d) => { + const Icon = d.icon; + const isSel = difficulty === d.id; + return ( + <button + key={d.id} + role="radio" + aria-checked={isSel} + onClick={() => setDifficulty(d.id)} + className={cn( + "group flex flex-col items-start gap-0.5 rounded-xl border px-3 py-2.5 text-left transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[var(--shadow-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-300", + isSel + ? diffSelectedClass(d.id) + : "border-stone-200 bg-white/70 text-stone-600 hover:bg-stone-50" + )} + > + <span className="flex items-center gap-1.5 text-sm font-semibold"> + <Icon className="h-4 w-4" /> + {t(`difficulty.${d.id}.label`)} + </span> + <span + className={cn( + "text-[11px] leading-tight", + isSel ? "opacity-80" : "text-stone-400" + )} + > + {t(`difficulty.${d.id}.hint`)} + </span> + </button> + ); + })} + </div> + </CardContent> + </Card> + + {/* match controls ⇄ replay panel (desktop; mobile uses the + fixed bottom sheet rendered near the action bar) */} + {isReplay ? ( + <div className="hidden lg:block"> + <ReplayPanel + replay={replay} + humanColor={humanColor} + muted={muted} + onToggleMute={toggleMute} + viewMode={viewMode} + onSwitchView={switchView} + onExport={() => setExportOpen(true)} + exportDisabled={moveCount === 0} + /> + </div> + ) : ( + <Card interactive className="glass"> + <CardHeader className="p-5 pb-3"> + <CardTitle className="text-base">{t("match.title")}</CardTitle> + </CardHeader> + <CardContent className="flex flex-col gap-3 p-5 pt-0"> + <div className="flex gap-2"> + <Button + variant={humanColor === BLACK ? "default" : "outline"} + size="sm" + className="flex-1 gap-1.5" + aria-pressed={humanColor === BLACK} + onClick={() => setHumanColor(BLACK)} + > + <User className="h-4 w-4" /> {t("match.humanFirst")} + </Button> + <Button + variant={humanColor === WHITE ? "default" : "outline"} + size="sm" + className="flex-1 gap-1.5" + aria-pressed={humanColor === WHITE} + onClick={() => setHumanColor(WHITE)} + > + <Bot className="h-4 w-4" /> {t("match.aiFirst")} + </Button> + </div> + <div + role="group" + aria-label={t("match.view")} + className="flex gap-2" + > + <Button + variant={viewMode === "2d" ? "default" : "outline"} + size="sm" + className="flex-1 gap-1.5" + aria-pressed={viewMode === "2d"} + title={t("match.view2dTitle")} + onClick={() => switchView("2d")} + > + <Grid3x3 className="h-4 w-4" /> {t("match.view2d")} + </Button> + <Button + variant={viewMode === "3d" ? "default" : "outline"} + size="sm" + className="flex-1 gap-1.5" + aria-pressed={viewMode === "3d"} + title={t("match.view3dTitle")} + onClick={() => switchView("3d")} + > + <Box className="h-4 w-4" /> {t("match.view3d")} + </Button> + </div> + <Separator /> + <div className="grid grid-cols-2 gap-2"> + <Button onClick={restart} className="gap-1.5"> + <RotateCcw className="h-4 w-4" /> {t("match.restart")} + </Button> + <Button + variant="secondary" + onClick={undo} + disabled={!canUndo} + className="gap-1.5" + > + <Undo2 className="h-4 w-4" /> {t("match.undo")} + </Button> + </div> + <Separator /> + <div className="grid grid-cols-2 gap-2"> + <Button + variant="secondary" + onClick={replay.enterReplay} + disabled={!replay.canEnterReplay} + title={ + thinking + ? t("match.thinking") + : moveCount === 0 + ? t("match.replayNone") + : t("match.replayThis") + } + className={cn( + "gap-1.5", + status !== "playing" && + moveCount > 0 && + "ring-2 ring-orange-300" + )} + > + <Film className="h-4 w-4" /> {t("match.replay")} + </Button> + <Button + variant="secondary" + onClick={() => setExportOpen(true)} + disabled={moveCount === 0} + title={ + moveCount === 0 ? t("match.exportNone") : t("match.export") + } + className="gap-1.5" + > + <Download className="h-4 w-4" /> {t("match.export")} + </Button> + </div> + </CardContent> + </Card> + )} + + {/* stats & achievements */} + <Card + interactive + className={cn( + "glass", + isReplay && "opacity-50 pointer-events-none" + )} + > + <CardHeader className="p-5 pb-3"> + <CardTitle className="flex items-center gap-2 text-base"> + <Trophy className="h-4 w-4 text-orange-500" /> {t("stats.title")} + </CardTitle> + </CardHeader> + <CardContent className="flex flex-col gap-4 p-5 pt-0"> + <div className="grid grid-cols-3 gap-2 text-center"> + {[ + { k: t("stats.win"), v: stats.wins, c: "text-emerald-600" }, + { k: t("stats.loss"), v: stats.losses, c: "text-rose-500" }, + { k: t("stats.draw"), v: stats.draws, c: "text-sky-600" }, + ].map((s) => ( + <div + key={s.k} + className="rounded-xl bg-white/70 py-2 shadow-[var(--shadow-soft)]" + > + <div + className={cn( + "text-lg font-bold tabular-nums", + s.c + )} + > + <AnimatedNumber value={s.v} /> + </div> + <div className="text-xs text-stone-400">{s.k}</div> + </div> + ))} + </div> + + {/* win-streak progress toward 5 */} + <div> + <div className="mb-1 flex items-center justify-between text-xs text-stone-500"> + <span>{t("stats.streak")}</span> + <span className="font-semibold text-orange-600"> + {stats.streak} / 5 + </span> + </div> + <div className="h-2.5 w-full overflow-hidden rounded-full bg-stone-200/70"> + <div + className="h-full rounded-full bg-gradient-to-r from-amber-400 to-orange-500 transition-[width] duration-700 ease-out" + style={{ width: `${streakPct * 100}%` }} + /> + </div> + </div> + + {/* achievement badges */} + <div className="grid grid-cols-3 gap-2.5"> + {ACHIEVEMENTS.map((a) => { + const earned = isEarned(a.id, stats); + const tone = TONE_CLASSES[a.tone]; + const Icon = ACH_ICON[a.icon] ?? Sparkles; + return ( + <div + key={a.id} + title={t(`achievements.${a.id}.desc`)} + className={cn( + "flex flex-col items-center gap-1 rounded-2xl border p-2.5 text-center transition-all duration-300 hover:-translate-y-0.5", + earned + ? cn("border-transparent", tone.bg, tone.glow) + : "border-stone-200 bg-stone-50/80 opacity-60 grayscale" + )} + > + <span + className={cn( + "flex h-9 w-9 items-center justify-center rounded-full transition-colors", + earned + ? cn(tone.bg, tone.text) + : "bg-stone-200 text-stone-400" + )} + > + <Icon className="h-5 w-5" /> + </span> + <span + className={cn( + "text-[11px] font-medium leading-tight", + earned ? "text-stone-700" : "text-stone-400" + )} + > + {t(`achievements.${a.id}.title`)} + </span> + </div> + ); + })} + </div> + </CardContent> + </Card> + </aside> + </div> + </div> + + {/* ---- mobile bottom action bar ---- */} + <div className="fixed inset-x-0 bottom-0 z-40 flex items-center justify-between gap-1 border-t border-white/60 bg-white/80 px-3 py-2.5 backdrop-blur-md pb-[calc(0.625rem_+_env(safe-area-inset-bottom))] lg:hidden"> + <Button + size="icon" + variant="ghost" + className="h-10 w-10" + aria-label={t("match.switchToggleFirst")} + title={t("match.switchToggleFirst")} + onClick={() => setHumanColor(humanColor === BLACK ? WHITE : BLACK)} + > + <User className="h-4 w-4" /> + </Button> + <Button + size="icon" + variant="ghost" + className="h-10 w-10" + aria-label={t("match.restart")} + title={t("match.restart")} + onClick={restart} + > + <RotateCcw className="h-4 w-4" /> + </Button> + <Button + size="icon" + variant="ghost" + className="h-10 w-10" + aria-label={t("match.undo")} + title={t("match.undo")} + disabled={!canUndo} + onClick={undo} + > + <Undo2 className="h-4 w-4" /> + </Button> + <Button + size="icon" + variant="ghost" + className="h-10 w-10" + aria-label={t("header.soundToggle")} + title={t("header.soundToggle")} + onClick={toggleMute} + > + {muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />} + </Button> + <Button + size="icon" + variant="ghost" + className="h-10 w-10" + aria-label={t("match.view")} + title={viewMode === "3d" ? t("match.switchTo2d") : t("match.switchTo3d")} + onClick={() => switchView(viewMode === "3d" ? "2d" : "3d")} + > + {viewMode === "2d" ? <Box className="h-4 w-4" /> : <Grid3x3 className="h-4 w-4" />} + </Button> + <Button + size="icon" + variant="ghost" + className="h-10 w-10" + aria-label={t("match.replay")} + title={ + thinking + ? t("match.thinking") + : moveCount === 0 + ? t("match.replayNone") + : t("match.replay") + } + disabled={!replay.canEnterReplay} + onClick={replay.enterReplay} + > + <Film className="h-4 w-4" /> + </Button> + <Button + size="icon" + variant="ghost" + className="h-10 w-10" + aria-label={t("match.export")} + title={moveCount === 0 ? t("match.exportNone") : t("match.export")} + disabled={moveCount === 0} + onClick={() => setExportOpen(true)} + > + <Download className="h-4 w-4" /> + </Button> + </div> + + {/* ---- mobile replay bottom sheet (covers the action bar while open) ---- */} + {isReplay && ( + <div className="fixed inset-x-0 bottom-0 z-50 px-2 pb-[calc(0.5rem_+_env(safe-area-inset-bottom))] lg:hidden"> + <ReplayPanel + compact + replay={replay} + humanColor={humanColor} + muted={muted} + onToggleMute={toggleMute} + viewMode={viewMode} + onSwitchView={switchView} + onExport={() => setExportOpen(true)} + exportDisabled={moveCount === 0} + /> + </div> + )} + + <ExportDialog + open={exportOpen} + onClose={() => setExportOpen(false)} + game={game} + humanColor={humanColor} + pushToast={pushToast} + /> + + <Toaster items={toasts} onDismiss={dismissToast} /> + {egg && <Confetti />} + {celebrate && <Confetti />} + </div> + ); +} 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<ExportFormat, string> = { + sgf: ".sgf", + json: ".json", + txt: ".txt", +}; + +export interface ExportDialogProps { + open: boolean; + onClose: () => void; + game: GameState; + humanColor: Player; + pushToast: (t: Omit<ToastItem, "id">) => void; +} + +export default function ExportDialog({ + open, + onClose, + game, + humanColor, + pushToast, +}: ExportDialogProps) { + const [format, setFormat] = useState<ExportFormat>("sgf"); + const [name, setName] = useState(""); + const inputRef = useRef<HTMLInputElement>(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: <Download className="h-5 w-5" />, + toneClass: "bg-emerald-50 text-emerald-700", + }); + onClose(); + } catch { + pushToast({ + title: t("export.exportFailed"), + toneClass: "bg-rose-50 text-rose-600", + }); + } + }; + + return ( + <div className="fixed inset-0 z-[80] flex items-center justify-center p-4"> + <div + className="absolute inset-0 bg-black/40 backdrop-blur-[2px]" + aria-hidden + onClick={onClose} + /> + <div + role="dialog" + aria-modal="true" + aria-label={t("export.title")} + className="relative w-full max-w-sm rounded-2xl border border-white/60 bg-white/95 p-5 shadow-[var(--shadow-float)] backdrop-blur-md" + > + <div className="mb-4 flex items-center justify-between"> + <h2 className="text-base font-semibold text-stone-800"> + {t("export.title")} + </h2> + <Button + size="icon" + variant="ghost" + className="h-8 w-8 text-stone-400" + aria-label={t("export.close")} + onClick={onClose} + > + <X className="h-4 w-4" /> + </Button> + </div> + + {/* format */} + <fieldset className="mb-4"> + <legend className="mb-2 text-sm font-medium text-stone-600"> + {t("export.format")} + </legend> + <div className="flex flex-col gap-2"> + {FORMATS.map((f) => ( + <label + key={f.id} + className={cn( + "flex cursor-pointer items-start gap-2.5 rounded-xl border px-3 py-2 transition-colors", + format === f.id + ? "border-orange-400 bg-orange-50" + : "border-stone-200 bg-white/70 hover:bg-stone-50" + )} + > + <input + type="radio" + name="export-format" + className="mt-1 accent-orange-500" + checked={format === f.id} + onChange={() => setFormat(f.id)} + /> + <span className="text-sm"> + <b className="text-stone-800">{f.name}</b> + <span className="ml-1.5 text-xs text-stone-500"> + {t(`export.${f.id}.desc`)} + </span> + </span> + </label> + ))} + </div> + </fieldset> + + {/* filename */} + <div className="mb-5"> + <label + htmlFor="export-filename" + className="mb-2 block text-sm font-medium text-stone-600" + > + {t("export.filename")} + </label> + <div className="flex items-center gap-1 rounded-xl border border-stone-200 bg-white px-3 py-2 focus-within:ring-2 focus-within:ring-orange-300"> + <input + id="export-filename" + ref={inputRef} + type="text" + value={name} + onChange={(e) => 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")} + /> + <span className="shrink-0 text-sm text-stone-400"> + {EXT[format]} + </span> + </div> + </div> + + <div className="flex justify-end gap-2"> + <Button variant="outline" onClick={onClose}> + {t("export.cancel")} + </Button> + <Button + className="gap-1.5" + disabled={!canExport} + title={game.moveCount === 0 ? t("export.emptyTip") : undefined} + onClick={doExport} + > + <Download className="h-4 w-4" /> {t("export.export")} + </Button> + </div> + </div> + </div> + ); +} 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<THREE.Mesh>(null); + const matRef = useRef<THREE.MeshStandardMaterial>(null); + const t = useRef(0); + const settled = useRef(false); + const dimP = useRef(0); + const igniteAt = useRef<number | null>(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 ( + <mesh + ref={ref} + position={[wx, REST_Y, wz]} + castShadow + receiveShadow + scale={[0, 0, 0]} + > + <sphereGeometry args={[STONE_RADIUS, 40, 28]} /> + <meshStandardMaterial + ref={matRef} + color={color} + roughness={player === BLACK ? 0.32 : 0.45} + metalness={0.15} + emissive={isWinStone || highlight ? GUIDE_COLOR : "#000000"} + emissiveIntensity={ + // §8.3-5a: reduced motion holds the winning five at a static 0.6. + isWinStone ? (REDUCED ? 0.6 : 0.25) : highlight ? 0.35 : 0 + } + /> + </mesh> + ); +} + +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 ( + <group> + {lines.map((l, i) => ( + <mesh key={i} position={l.pos}> + <boxGeometry args={l.size} /> + <meshStandardMaterial color="#5b4632" roughness={0.8} /> + </mesh> + ))} + {STAR_POINTS.map(([x, y]) => { + const [wx, , wz] = gridToWorld(x, y); + return ( + <mesh key={`sp-${x}-${y}`} position={[wx, 0.02, wz]}> + <cylinderGeometry args={[0.08, 0.08, 0.03, 16]} /> + <meshStandardMaterial color="#3f2f1d" roughness={0.8} /> + </mesh> + ); + })} + </group> + ); +} + +/** + * 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<THREE.Mesh>(null); + const r2 = useRef<THREE.Mesh>(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 ( + <group> + <mesh + ref={r1} + geometry={geo} + position={[wx, 0.055, wz]} + rotation={[-Math.PI / 2, 0, 0]} + renderOrder={2} + > + <meshBasicMaterial + color={DUST_COLOR} + transparent + opacity={0.5} + depthWrite={false} + /> + </mesh> + <mesh + ref={r2} + geometry={geo} + position={[wx, 0.052, wz]} + rotation={[-Math.PI / 2, 0, 0]} + renderOrder={2} + scale={[0.8, 0.8, 1]} + > + <meshBasicMaterial + color={DUST_COLOR} + transparent + opacity={0} + depthWrite={false} + /> + </mesh> + </group> + ); +} + +/** + * 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 ( + <HoverPreviewInner + hover={hover} + player={player} + valid={valid} + interactive={interactive} + /> + ); +} + +function HoverPreviewInner({ + hover, + player, + valid, + interactive, +}: { + hover: Move; + player: Player; + valid: boolean; + interactive: boolean; +}) { + const ghost = useRef<THREE.Mesh>(null); + const matRef = useRef<THREE.MeshStandardMaterial>(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 ( + <group> + {/* ghost stone */} + <mesh ref={ghost} position={[wx, REST_Y, wz]} scale={[1, STONE_FLAT, 1]}> + <sphereGeometry args={[STONE_RADIUS, 32, 24]} /> + <meshStandardMaterial + ref={matRef} + color={color} + transparent + opacity={valid ? 0.45 : 0.18} + roughness={0.4} + /> + </mesh> + {/* guide crosshair through the hovered intersection (§3.3: static) */} + {showCross && ( + <> + <mesh position={[0, 0.03, wz]}> + <boxGeometry args={[PLANE - 2, 0.04, 0.06]} /> + <meshStandardMaterial + color={GUIDE_COLOR} + emissive={GUIDE_COLOR} + emissiveIntensity={0.6} + transparent + opacity={0.7} + /> + </mesh> + <mesh position={[wx, 0.03, 0]}> + <boxGeometry args={[0.06, 0.04, PLANE - 2]} /> + <meshStandardMaterial + color={GUIDE_COLOR} + emissive={GUIDE_COLOR} + emissiveIntensity={0.6} + transparent + opacity={0.7} + /> + </mesh> + </> + )} + </group> + ); +} + +/** + * 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<THREE.MeshStandardMaterial>(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 ( + <mesh position={mid} rotation={[0, -angle, 0]}> + <boxGeometry args={[len, 0.08, 0.08]} /> + <meshStandardMaterial + ref={matRef} + color={GUIDE_COLOR} + emissive={GUIDE_COLOR} + emissiveIntensity={0.9} + /> + </mesh> + ); +} + +/** A soft, pulsing radial glow used during the victory celebration. */ +function CelebrationGlow() { + const ref = useRef<THREE.Mesh>(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 ( + <mesh ref={ref} position={[0, 0.06, 0]} rotation={[-Math.PI / 2, 0, 0]}> + <circleGeometry args={[9, 48]} /> + <meshBasicMaterial + color="#ff8a3d" + transparent + opacity={0.12} + blending={THREE.AdditiveBlending} + depthWrite={false} + /> + </mesh> + ); +} + +/** + * 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<THREE.Mesh>(null); + const inner = useRef<THREE.Mesh>(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 ( + <group> + {!REDUCED && ( + <mesh + ref={outer} + position={[wx, 0.219, wz]} + rotation={[-Math.PI / 2, 0, 0]} + > + <ringGeometry args={[0.42, 0.54, 40]} /> + <meshBasicMaterial + color={GUIDE_COLOR} + transparent + opacity={0.5} + depthWrite={false} + /> + </mesh> + )} + <mesh + ref={inner} + position={[wx, 0.216, wz]} + rotation={[-Math.PI / 2, 0, 0]} + > + <ringGeometry args={[0.46, 0.52, 40]} /> + <meshBasicMaterial + color={GUIDE_COLOR} + transparent + opacity={0.4} + depthWrite={false} + /> + </mesh> + </group> + ); +} + +/** + * 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 ( + <group> + {[-7.6, 7.6].map((z) => ( + <mesh key={`h${z}`} position={[0, 0.026, z]} material={mat}> + <boxGeometry args={[15.2, 0.02, 0.08]} /> + </mesh> + ))} + {[-7.6, 7.6].map((x) => ( + <mesh key={`v${x}`} position={[x, 0.026, 0]} material={mat}> + <boxGeometry args={[0.08, 0.02, 15.2]} /> + </mesh> + ))} + </group> + ); +} + +function InteractionPlane({ + interactive, + onHover, + onPlace, +}: { + interactive: boolean; + onHover: (m: Move | null) => void; + onPlace: (m: Move) => void; +}) { + const handle = (e: ThreeEvent<PointerEvent>) => { + const g = worldToGrid(e.point); + onHover(g); + return g; + }; + return ( + <mesh + rotation={[-Math.PI / 2, 0, 0]} + position={[0, 0.005, 0]} + onPointerMove={(e) => { + e.stopPropagation(); + handle(e); + }} + onPointerOut={() => onHover(null)} + onClick={(e) => { + e.stopPropagation(); + const g = worldToGrid(e.point); + if (g && interactive) onPlace(g); + }} + > + <planeGeometry args={[PLANE, PLANE]} /> + <meshBasicMaterial transparent opacity={0} depthWrite={false} /> + </mesh> + ); +} + +// --------------------------------------------------------------------------- +// 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<ViewMode | null>(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<Move | null>(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<string, number>(); + 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 ( + <> + <ambientLight intensity={0.7} /> + <hemisphereLight args={["#fff7ed", "#7c5c3e", 0.45]} /> + <directionalLight + position={[6, 12, 8]} + intensity={1.1} + castShadow + shadow-mapSize-width={1024} + shadow-mapSize-height={1024} + shadow-camera-left={-12} + shadow-camera-right={12} + shadow-camera-top={12} + shadow-camera-bottom={-12} + /> + <directionalLight position={[-8, 6, -4]} intensity={0.3} /> + + <CameraRig viewMode={viewMode} onTweeningChange={setCamTweening} /> + + <InteractionPlane + interactive={interactive && !camTweening} + onHover={(m) => { + setHover(m); + onHover(m); + }} + onPlace={onPlace} + /> + + {/* board base */} + <RoundedBox + args={[PLANE, 0.8, PLANE]} + radius={0.35} + smoothness={4} + position={[0, -0.4, 0]} + receiveShadow + > + <meshStandardMaterial color={BOARD_COLOR} roughness={0.7} /> + </RoundedBox> + + {/* soft grounding shadow under the floating board */} + <ContactShadows + position={[0, -0.86, 0]} + opacity={0.4} + scale={26} + blur={3} + far={3} + color="#3f2f1d" + /> + + <GridLines /> + + {frameAlive && ( + <ThinkingFrame thinking={!!thinking} onFadedOut={releaseFrame} /> + )} + + {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 ( + <Stone + key={key} + x={s.x} + y={s.y} + player={s.p} + highlight={isLast} + entrance={isLast ? "bounce" : "pop"} + viewMode={viewMode} + winIndex={wi} + // §5.3 / §7.2: dim the non-decisive stones whenever the game ended + // with a line (either side), or half as much on a draw. + dimK={winLine ? (wi === undefined ? 1 : 0) : drawn ? 0.5 : 0} + dimMs={winLine ? 500 : 600} + /> + ); + })} + + {/* §2: replays on every new lastMove (keyed), self-unmounts after 470ms */} + {lastMove && !REDUCED && ( + <ImpactRipple key={`${lastMove.x}-${lastMove.y}`} move={lastMove} /> + )} + + {/* §4.2 avoidance: never shown under a win, never fought with a same-cell hover */} + {lastMove && !winLine && !hoverOnLastMove && ( + <LastMovePulse move={lastMove} fading={drawn} /> + )} + + <HoverPreview + hover={hover} + player={currentPlayer} + valid={hoverValid} + interactive={interactive} + /> + + {winLine && winLine.length >= 2 && ( + <WinLine line={winLine} animated /> + )} + + {celebrate && !REDUCED && <CelebrationGlow />} + + <OrbitControls + makeDefault + enablePan={false} + enableZoom + enableRotate={initialMode === "3d"} + minDistance={14} + maxDistance={30} + minPolarAngle={initialMode === "2d" ? 0 : 0.15} + maxPolarAngle={initialMode === "2d" ? 0 : Math.PI / 2.6} + target={[0, 0, 0]} + /> + </> + ); +} + +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 ( + <div className="absolute inset-0 overflow-hidden rounded-xl" data-gomoku-board> + <Canvas + shadows + dpr={[1, 2]} + camera={{ position: initialPos, fov: 42 }} + className="h-full w-full rounded-xl" + style={{ width: "100%", height: "100%", display: "block" }} + > + <color attach="background" args={["#f6efe6"]} /> + <Scene {...props} /> + </Canvas> + </div> + ); +} 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 ( + <Button + variant="outline" + size="sm" + onClick={toggle} + className="gap-1.5 px-2.5" + aria-label={t("lang.switch")} + title={t("lang.switch")} + > + <Languages className="h-4 w-4" /> + <span className="text-xs font-semibold">{isZh ? "EN" : "中"}</span> + </Button> + ); +} 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 ( + <Card className={cn("glass", compact && "shadow-[var(--shadow-float)]")}> + <CardContent + className={cn( + "flex flex-col", + compact ? "gap-2.5 p-3" : "gap-3 p-5" + )} + > + {/* header: badge + exit */} + <div className="flex items-center justify-between"> + <Badge className="gap-1.5 bg-stone-800 text-white hover:bg-stone-800"> + <Film className="h-3.5 w-3.5" /> {t("replay.mode")} + </Badge> + <Button + size="sm" + variant="ghost" + className="h-9 gap-1 text-stone-500" + onClick={replay.exitReplay} + aria-label={t("replay.exitReplay")} + > + <X className="h-4 w-4" /> {t("replay.exit")} + </Button> + </div> + + {/* current move info */} + <div + aria-live="polite" + className="flex items-center gap-2 rounded-xl bg-white/70 px-3 py-2 text-sm text-stone-700 shadow-[var(--shadow-soft)]" + > + {currentMove && ( + <span + aria-hidden + className={cn( + "h-3 w-3 shrink-0 rounded-full border", + currentMove.player === BLACK + ? "border-stone-800 bg-stone-800" + : "border-stone-300 bg-white" + )} + /> + )} + <span className="truncate font-medium">{info}</span> + </div> + + {/* progress */} + <div className="flex items-center gap-3"> + <Slider + aria-label={t("replay.progress")} + min={0} + max={N} + step={1} + value={[currentStep]} + onValueChange={(v) => replay.goTo(v[0])} + className="flex-1" + /> + <span className="shrink-0 text-xs tabular-nums text-stone-500"> + {currentStep} / {N} + </span> + </div> + + {/* transport controls */} + <div className="flex items-center justify-center gap-1.5"> + <Button + size="icon" + variant="outline" + className="h-11 w-11" + aria-label={t("replay.first")} + disabled={atStart} + onClick={() => replay.goTo(0)} + > + <SkipBack className="h-4 w-4" /> + </Button> + <Button + size="icon" + variant="outline" + className="h-11 w-11" + aria-label={t("replay.prev")} + disabled={atStart} + onClick={replay.stepBack} + > + <ChevronLeft className="h-4 w-4" /> + </Button> + <Button + size="icon" + className="h-11 w-11" + aria-label={ + playing + ? t("replay.pause") + : atEnd + ? t("replay.replayAgain") + : t("replay.play") + } + aria-pressed={playing} + onClick={replay.togglePlay} + > + {playing ? ( + <Pause className="h-4 w-4" /> + ) : atEnd ? ( + <RotateCw className="h-4 w-4" /> + ) : ( + <Play className="h-4 w-4" /> + )} + </Button> + <Button + size="icon" + variant="outline" + className="h-11 w-11" + aria-label={t("replay.next")} + disabled={atEnd} + onClick={replay.stepForward} + > + <ChevronRight className="h-4 w-4" /> + </Button> + <Button + size="icon" + variant="outline" + className="h-11 w-11" + aria-label={t("replay.last")} + disabled={atEnd} + onClick={() => replay.goTo(N)} + > + <SkipForward className="h-4 w-4" /> + </Button> + </div> + + {/* speed */} + <div + role="group" + aria-label={t("replay.speed")} + className="flex items-center justify-center gap-1.5" + > + {SPEEDS.map((s) => ( + <Button + key={s.ms} + size="sm" + variant={replay.speedMs === s.ms ? "default" : "outline"} + aria-pressed={replay.speedMs === s.ms} + className="h-9 flex-1 px-0 tabular-nums" + onClick={() => replay.setSpeed(s.ms)} + > + {s.label} + </Button> + ))} + </div> + + <Separator /> + + {/* companions that remain useful during replay */} + <div className="flex items-center gap-2"> + <Button + size="icon" + variant="ghost" + className="h-10 w-10" + aria-label={t("replay.soundToggle")} + onClick={onToggleMute} + > + {muted ? ( + <VolumeX className="h-4 w-4" /> + ) : ( + <Volume2 className="h-4 w-4" /> + )} + </Button> + <Button + size="icon" + variant="ghost" + className="h-10 w-10" + aria-label={t("replay.view")} + title={ + viewMode === "3d" + ? t("match.switchTo2d") + : t("match.switchTo3d") + } + onClick={() => onSwitchView(viewMode === "3d" ? "2d" : "3d")} + > + {viewMode === "2d" ? ( + <Box className="h-4 w-4" /> + ) : ( + <Grid3x3 className="h-4 w-4" /> + )} + </Button> + <Button + size="sm" + variant="secondary" + className="ml-auto h-10 gap-1.5" + disabled={exportDisabled} + onClick={onExport} + > + <Download className="h-4 w-4" /> {t("replay.export")} + </Button> + </div> + </CardContent> + </Card> + ); +} 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<HTMLDivElement>, + VariantProps<typeof badgeVariants> {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( + <div className={cn(badgeVariants({ variant }), className)} {...props} /> + ); +} + +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<HTMLButtonElement>, + VariantProps<typeof buttonVariants> { + 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<HTMLButtonElement, ButtonProps>( + ( + { className, variant, size, asChild = false, noRipple = false, onClick, children, ...props }, + ref + ) => { + const [ripples, setRipples] = React.useState<Ripple[]>([]); + + const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { + 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 ( + <Comp + ref={ref} + onClick={handleClick} + className={cn( + buttonVariants({ variant, size }), + "relative overflow-hidden transition-transform duration-150 active:scale-[0.97] active:duration-75", + className + )} + {...props} + > + {asChild ? ( + children + ) : ( + <> + <span className="relative z-10 inline-flex items-center gap-2"> + {children} + </span> + <span className="pointer-events-none absolute inset-0 z-0 overflow-hidden rounded-[inherit]"> + {ripples.map((r) => ( + <span + key={r.id} + className="absolute rounded-full bg-white/40 animate-pop-in" + style={{ + left: r.x, + top: r.y, + width: r.size, + height: r.size, + }} + /> + ))} + </span> + </> + )} + </Comp> + ); + } +); +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<HTMLDivElement> & { interactive?: boolean } +>(({ className, interactive, ...props }, ref) => ( + <div + ref={ref} + className={cn( + "rounded-xl border bg-card text-card-foreground shadow-sm transition-all duration-300", + interactive && + "hover:-translate-y-0.5 hover:shadow-[var(--shadow-card)] hover:border-orange-200", + className + )} + {...props} + /> +)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex flex-col space-y-1.5 p-6", className)} + {...props} + /> +)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("font-semibold leading-none tracking-tight", className)} + {...props} + /> +)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> +)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex items-center p-6 pt-0", className)} + {...props} + /> +)); +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<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> +>(({ className, ...props }, ref) => ( + <LabelPrimitive.Root + ref={ref} + className={cn( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", + className + )} + {...props} + /> +)); +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<typeof SeparatorPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + <SeparatorPrimitive.Root + ref={ref} + decorative={decorative} + orientation={orientation} + className={cn( + "shrink-0 bg-border", + orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", + className + )} + {...props} + /> + ) +); +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<typeof SliderPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> +>(({ className, ...props }, ref) => ( + <SliderPrimitive.Root + ref={ref} + className={cn( + "relative flex w-full touch-none select-none items-center", + className + )} + {...props} + > + <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary"> + <SliderPrimitive.Range className="absolute h-full bg-primary" /> + </SliderPrimitive.Track> + <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" /> + </SliderPrimitive.Root> +)); +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<typeof SwitchPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> +>(({ className, ...props }, ref) => ( + <SwitchPrimitives.Root + className={cn( + "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", + className + )} + {...props} + ref={ref} + > + <SwitchPrimitives.Thumb + className={cn( + "pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" + )} + /> + </SwitchPrimitives.Root> +)); +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 ( + <div className="pointer-events-none fixed inset-x-0 top-4 z-[60] flex flex-col items-center gap-2 px-4"> + {items.map((t) => ( + <div + key={t.id} + role="status" + aria-live="polite" + className="pointer-events-auto flex w-full max-w-sm items-center gap-3 rounded-2xl border border-white/60 bg-white/85 px-4 py-3 shadow-[var(--shadow-float)] backdrop-blur-md animate-in fade-in slide-in-from-top-4 zoom-in-95 duration-300" + > + {t.icon && ( + <span + className={ + "flex h-9 w-9 shrink-0 items-center justify-center rounded-full " + + (t.toneClass ?? "bg-orange-100 text-orange-600") + } + > + {t.icon} + </span> + )} + <div className="min-w-0 flex-1"> + <div className="text-sm font-semibold text-stone-800"> + {t.title} + </div> + {t.desc && ( + <div className="truncate text-xs text-stone-500">{t.desc}</div> + )} + </div> + <button + onClick={() => 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" + > + <svg + viewBox="0 0 24 24" + className="h-4 w-4" + fill="none" + stroke="currentColor" + strokeWidth="2.5" + strokeLinecap="round" + > + <path d="M6 6l12 12M18 6L6 18" /> + </svg> + </button> + </div> + ))} + </div> + ); +} 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<ToastItem, "id">) => void; + dismissToast: (id: number) => void; +} + +export function useGomoku(): UseGomoku { + const { t } = useTranslation(); + const pluginActive = usePluginActivity(); + const [game, setGame] = useState<engine.GameState>(loadGame); + const [difficulty, setDifficultyState] = useState<Difficulty>(loadDifficulty); + const [humanColor, setHumanColorState] = useState<Player>(loadHumanColor); + const [thinking, setThinking] = useState(false); + const [hover, setHoverState] = useState<Move | null>(null); + const [stats, setStats] = useState<GameStats>(() => loadStats()); + const [muted, setMutedState] = useState<boolean>(() => isMuted()); + const [elapsedMs, setElapsedMs] = useState(loadElapsedMs); + const [toasts, setToasts] = useState<ToastItem[]>([]); + + 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<number | null>(null); + const toastId = useRef(0); + const prevHoverRef = useRef<Move | null>(null); + const lastHoverPlayRef = useRef(0); + const thinkingWasTrueRef = useRef(false); + + // ---- toast helpers ---- + const pushToast = useCallback((t: Omit<ToastItem, "id">) => { + 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<boolean>).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<ReplayMode>("idle"); + const [currentStep, setCurrentStep] = useState(0); + const [speedMs, setSpeedMs] = useState(800); // 1× = 800ms per move + const [snapshot, setSnapshot] = useState<ReplaySnapshot | null>(null); + const [moves, setMoves] = useState<ReplayMove[]>([]); + + 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<unknown>(STORAGE_KEY, "3d"); + return value === "2d" || value === "3d" ? value : "3d"; +} + +/** + * 提供持久化的棋盘视图状态。 + * @returns 当前视图和更新函数。 + */ +export function useViewMode(): [ViewMode, (mode: ViewMode) => void] { + const [viewMode, setViewModeState] = useState<ViewMode>(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<unknown>("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<string, LucideIcon> = { + 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<unknown>(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<Difficulty, DifficultyConfig> = { + 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<ExportFormat, string> = { + sgf: ".sgf", + json: ".json", + txt: ".txt", +}; + +const MIME: Record<ExportFormat, string> = { + 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<GameStatus>(["playing", "black_win", "white_win", "draw"]); +const DIFFICULTY_VALUES = new Set<Difficulty>(["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<unknown>(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<unknown>(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<unknown>(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<unknown>(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<GameState, "board" | "moveCount" | "history">; + +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<unknown>(STORAGE_KEY, null); + if (!value || typeof value !== "object") return emptyStats(); + const stats = { ...emptyStats(), ...(value as Partial<GameStats>) }; + 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<Stone>(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<number>(); + 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<T>(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<T>(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<boolean>(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( + <React.StrictMode> + <App /> + </React.StrictMode> +); 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 @@ +/// <reference types="vite/client" /> 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<unknown>} 页面脚本执行结果。 + */ +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<string>} 设置页正文。 + */ +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<unknown>} 页面脚本结果。 + */ +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<boolean>} 是否找到棋盘并发送了点击。 + */ +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"; + }, + }, + }, + }, +});