diff --git a/.gitignore b/.gitignore index 7f9d654..9b28402 100644 --- a/.gitignore +++ b/.gitignore @@ -89,4 +89,8 @@ typings/ AGENTS.md docs-site/.vitepress/dist .vscode -.ref \ No newline at end of file +.ref + +.codex +.bagakit +.worktrees diff --git a/README.md b/README.md index c13127d..7162f05 100644 --- a/README.md +++ b/README.md @@ -40,14 +40,62 @@ Options: --format, -f export format (defaults to "json"). Available values are resolved from the serializer registry at runtime (json, js, ts, ts-interface, jsonx, go, csharp by default). + --context, -c context directory, context.*.json file, or full context JSON file + --inspect print machine-readable diagnostics for a single input file + --validate validate a single input file and print machine-readable result + --json suppress incidental logs for inspect/validate machine output -h, --help Show help [boolean] --version Show version number [boolean] ``` -默认会扫描输入目录下的 .xls/.xlsx/.csv 表格文件,可直接与 Excel 模板共用标注约定。 +默认会扫描输入目录下的 .xls/.xlsx/.csv 表格文件,可直接与 Excel 模板共用标注约定。目录输入会默认加载同目录下的 `context.*.json`;单文件输入需要上下文时可使用 `--context ./dir`、`--context ./context.enums.json` 或 `--context ./context.json`。 > 提示:选择 `-f ts` 时会生成两份文件:`Solution.ts`(含数据 + 仓库实例)以及 `
.ts`(纯类型与仓库定义)。`-f ts-interface` 仅输出类型文件。 +调试单文件时可使用: + +```bash +tables -i ./example/items.csv --inspect +``` + +`--inspect` 会向 stdout 输出 JSON 诊断,包含实际 sheet 名、标记列、描述行、schema model、convert 计数和错误信息,便于脚本或 AI agent 定位表格问题。 + +#### AI-friendly validate JSON + +当前代码提供 `--inspect` / `inspectTableFile` 作为完整诊断入口,也提供 `--validate` / `validateTableFile` 作为更适合 AI agent 和 CI 消费的校验入口: + +```bash +tables -i ./example/items.csv --validate +``` + +`--validate` 只支持单文件输入,始终向 stdout 输出 JSON。校验失败时退出码为非 0。`--json` 可进一步抑制附带日志,保证机器通道更安静。当前输出结构: + +```json +{ + "success": false, + "fileName": "items", + "sheetName": "Sheet1", + "diagnostics": { + "fileName": "items", + "sheetName": "Sheet1", + "convert": { "present": false, "tidCount": 0, "resultCount": 0 } + }, + "errors": [ + { + "severity": "error", + "code": "convert_error", + "message": "required value is empty", + "location": { "sheetName": "Sheet1", "row": 4, "column": "A" }, + "diagnosticPath": "convert.error", + "details": { "tableName": "items" } + } + ], + "warnings": [] +} +``` + +自有转换错误会优先使用结构化 metadata 生成 `location` / `details`,再回退到兼容旧错误文案的解析逻辑。`--validate --json` 还会在读取 context 后先校验 `context.enums.*` 的跨表引用,缺失目标表或目标字段不是 alias 列时返回 `context_ref_error`。测试设计应覆盖:合法表返回 `success: true` 且退出码为 0;转换错误返回 `success: false` 且退出码非 0,并尽量携带 `row`、`column`、`fieldPath` 或 `details`;TID 冲突生成 `tid_collision`;`--verbose` 或上下文加载日志不污染 stdout JSON。 + ### API - Out-of-box API : `readAndTranslate(filepath [,option])` @@ -272,15 +320,15 @@ const specific = enemiesRepo.get(toEnemiesTID('50010001')); } ``` 其中 `"enums"` 对应上一步 JSON 的键名;可写成数组导出多个命名空间。 -3. **在脚本中加载上下文**:CLI 默认不会读取上下文,推荐在自定义脚本中调用 `loadContext`: +3. **在脚本中加载上下文**:目录输入时 CLI 会自动读取同目录下的 `context.*.json`;自定义脚本或单文件输入推荐显式调用 `loadContextPath`: ```ts - const { loadContext, serialize, tsInterfaceSerializer } = require('@khgame/tables'); - const ctx = loadContext('./example'); + const { loadContextPath, serialize, tsInterfaceSerializer } = require('@khgame/tables'); + const ctx = loadContextPath('./example'); serialize('./example/items.xlsx', './out', { 'Items.ts': tsInterfaceSerializer }, ctx); ``` - `loadContext(dir)` 会自动读取 `dir` 下符合 `context.*.json` 的文件,并在序列化阶段提供 `context.enums.*`、`context.meta.*` 等信息。 + `loadContextPath(path)` 会读取 context 目录、单个 `context..json`,或完整 context JSON 文件,并在序列化阶段提供 `context.enums.*`、`context.meta.*` 等信息。 4. **表头写成 `enum`**:当标记行使用 `enum`(或 `enum`)时,`tableSchema`/`tableConvert` 会校验单元格值是否存在于上下文枚举中;TS/Go/C# 序列化器则会在产物中生成 `TableContext.Rarity` 引用。 ## TS 输出结构 diff --git a/docs-site/reference/api.md b/docs-site/reference/api.md index 9c5f07b..fc0a8cb 100644 --- a/docs-site/reference/api.md +++ b/docs-site/reference/api.md @@ -26,15 +26,60 @@ serialize('example/example.xlsx', 'out', { }) ``` +## inspectTableFile(pathIn, options?, context?) + +- 读取单个 Excel/CSV 并返回机器可读诊断对象 +- 诊断包含实际 sheet 名、mark/desc 信息、schema model、convert 计数、索引与 alias 计数与转换错误 +- convert 阶段自有错误会在 `diagnostics.convert.errorMeta` 中附带结构化错误码、定位和细节;第三方或旧式错误仍保留字符串错误 +- 适合调试表头、生成 AI 修复上下文,或在导出前做轻量检查 + +```js +const { inspectTableFile, loadContext } = require('@khgame/tables') +const ctx = loadContext('example') +const diagnostics = inspectTableFile('example/items.csv', {}, ctx) +console.log(diagnostics.convert.tidCount) +``` + +## validateTableFile(pathIn, options?, context?) + +`validateTableFile` 是 `inspectTableFile` 之上的稳定校验摘要,适合 AI agent、CI 和编辑器集成直接调用: + +- 返回 `{ success, fileName, sheetName?, diagnostics, errors, warnings }` +- `errors[]` / `warnings[]` 使用 `{ severity, code, message, location?, diagnosticPath?, details? }` +- `location` 面向编辑器和 agent 定位,当前会尽量包含 `sheetName`、`row`、`column`、`fieldPath` +- `details` 面向自动修复和 CI 展示,当前会尽量包含 `tableName`、`tid`、`sourceAlias`、`rawValue`、`suggestedType`、`duplicateAliases` +- 当前错误码包括 `schema_error`、`convert_error`、`tid_collision`、`context_ref_error`、`inspect_error` +- 自有 convert 错误会优先使用结构化 metadata 生成 `location` / `details`,再回退到兼容旧错误文案的解析逻辑 +- CLI `--validate --json` 会先校验 context enum refs;缺失目标表或目标字段不是 alias 列时返回 `context_ref_error` +- 自身不写文件;CLI machine mode 会隔离底层偶发日志,方便测试和 AI agent 直接调用 +- `diagnostics` 保留完整 `inspectTableFile` 结果,便于定位 sheet、mark/desc、schema 和 convert 信息 + +调用方式: + +```js +const { validateTableFile, loadContext } = require('@khgame/tables') +const ctx = loadContext('example') +const report = validateTableFile('example/items.csv', {}, ctx) + +if (!report.success) { + console.error(JSON.stringify(report.errors, null, 2)) +} +``` + ## 上下文(Context)与枚举 - `loadContext(dir)` 会将 `dir` 下符合 `context.*.json` 的文件聚合为上下文对象 +- `loadContextPath(path)` 可接收 context 目录、`context..json` 文件或完整 context JSON 文件;CLI `--context` 与该 API 共享同一加载语义 +- `validateContextReferences(pathOrContext, baseDir?)` 会检查 `context.enums.*` 中的表引用,并返回 `{ success, references, errors, warnings }` +- `collectContextEnumReferences(context)` 可只收集 enum refs,不读取目标表 - `serializeContext(dirOut, serializers, context)` 会生成 `context.ts`(供 TS 序列化器引用),并可输出枚举 - 在 `context.meta.exports.enum = [ 'enums', ... ]` 中声明要导出的枚举集合名 ```js -const { loadContext, serializeContext, tsSerializer } = require('@khgame/tables') -const ctx = loadContext('example') +const { loadContextPath, serializeContext, tsSerializer, validateContextReferences } = require('@khgame/tables') +const check = validateContextReferences('example') +if (!check.success) console.error(check.errors) +const ctx = loadContextPath('example') serializeContext('out', [tsSerializer], ctx) // 生成 out/context.ts ``` diff --git a/docs-site/reference/cli.md b/docs-site/reference/cli.md index 738531d..c5444d7 100644 --- a/docs-site/reference/cli.md +++ b/docs-site/reference/cli.md @@ -11,6 +11,10 @@ Options: --verbose, -v 详细日志 [boolean] --fail-fast 尝试在首次致命错误即停止(尽力) [boolean] --strict 将警告视为错误(TID 冲突报错) [boolean] + --context, -c context 目录或 context.*.json 文件 + --inspect 单文件机器可读诊断输出 [boolean] + --validate 单文件机器可读校验输出 [boolean] + --json inspect/validate 抑制附带日志 [boolean] -h, --help 帮助 --version 版本 ``` @@ -25,11 +29,82 @@ tables -i ./example -o ./out-ts -f ts ```bash tables -i ./example/example.xlsx -o ./out -f json --silent ``` +- 使用显式上下文目录: +```bash +tables -i ./example/items.csv -o ./out -f ts-interface --context ./example +``` +- 输出单文件诊断 JSON: +```bash +tables -i ./example/items.csv --inspect +``` +- 输出单文件校验 JSON: +```bash +tables -i ./example/items.csv --validate +``` - 严格模式(TID 冲突非 0 退出): ```bash tables -i ./example -o ./out -f json --strict ``` +上下文加载: +- 目录输入默认加载该目录下的 `context.*.json`。 +- 文件输入默认不猜测上下文;需要枚举、索引或 meta 时使用 `--context`。 +- `--context` 可指向目录,也可指向单个 `context..json` 文件。 + +`--inspect` 只支持单文件输入,并向 stdout 输出 JSON,适合脚本或 AI agent 读取。诊断包括实际 sheet 名、mark/desc 信息、schema model、convert 计数、索引与 alias 计数以及转换错误信息。 + +## Validate JSON + +`--validate` 复用单文件解析与转换诊断,输出更稳定的校验报告,方便 AI agent 和 CI 直接消费: + +```bash +tables -i ./example/items.csv --validate +``` + +- 仅支持单文件输入。 +- stdout 输出 JSON;失败时退出码为非 `0`。 +- `errors[].code` 当前包括 `schema_error`、`convert_error`、`tid_collision`、`inspect_error`、`invalid_input`、`unsupported_file`、`preflight_error`。 +- `errors[].location` 会尽量补充 `sheetName`、`row`、`column`、`fieldPath`;`errors[].details` 会尽量补充 `tableName`、`tid`、`sourceAlias`、`rawValue`、`suggestedType` 等可定位信息。 +- `diagnostics` 保留 `--inspect` 的完整诊断结果,便于定位 sheet、mark/desc、schema 和 convert 信息。 +- 可与 `--context ./dir` 或 `--context ./context.enums.json` 组合使用。 +- `--inspect` / `--validate` 始终向 stdout 输出 JSON;使用 `--json` 时,附带日志不会写入 stderr。 + +JSON 形状: + +```json +{ + "success": false, + "fileName": "items", + "sheetName": "Sheet1", + "diagnostics": { + "fileName": "items", + "sheetName": "Sheet1", + "convert": { + "present": false, + "tidCount": 0, + "resultCount": 0 + } + }, + "errors": [ + { + "severity": "error", + "code": "convert_error", + "message": "required value is empty", + "location": { "sheetName": "Sheet1", "row": 4, "column": "A" }, + "diagnosticPath": "convert.error", + "details": { "tableName": "items" } + } + ], + "warnings": [] +} +``` + +建议测试用例: +- 有效 CSV:`stdout` 可被 `JSON.parse` 解析,`success === true`,退出码为 `0`。 +- 必填字段缺失或 schema/convert 失败:`success === false`,`errors[0]` 带 `severity/code/message`,退出码非 `0`。 +- TID 冲突:`errors[].code` 包含 `tid_collision`,并带 `diagnosticPath`、`details.tid` 和可用策略提示。 +- `--verbose --context --json`:stdout 仍是纯 JSON,附带日志不污染输出通道。 + 命名规则:输出文件名自动按输入名驼峰化(如 `example.xlsx` -> `Example.json`)。 推荐输出目录: diff --git a/example/game_07_skill_gomoku/5stone_design.png b/example/game_07_skill_gomoku/5stone_design.png deleted file mode 100644 index c9ab234..0000000 Binary files a/example/game_07_skill_gomoku/5stone_design.png and /dev/null differ diff --git a/example/game_07_skill_gomoku/UIDESIGN.md b/example/game_07_skill_gomoku/UIDESIGN.md index b4e3885..c2a7f9a 100644 --- a/example/game_07_skill_gomoku/UIDESIGN.md +++ b/example/game_07_skill_gomoku/UIDESIGN.md @@ -90,7 +90,39 @@ 2. 卡牌交互 - 悬停:显示详细信息(宣言、效果、稀有度)。 - 拖拽至棋盘:若为可选目标技能(如“飞沙走石”),可指向目标棋子后确认。 -- 右键:查看关联的“反制卡”提示(显示哪些卡能反制当前技能)。 + +### 3. 卡牌一览(以表为准) + +- 攻击类(Attack) + +| tid | 名称 | 类型 | 速度/时机 | 依赖 | 主要效果 | 反制/免疫 | 备注/失败条件 | 台词 | +|---|---|---|---|---|---|---|---|---| +| 1001 | 飞沙走石 | Attack/Standard | Normal/PreMove | - | 移除敌方棋子至什刹海;目标落点下回合封禁;自身放弃本回合落子 | 可被 拾金不昧(1008)、擒拿(1009) | 被擒拿或拾金不昧打断时取消 | 你管这个叫五子棋? | +| 1002 | 静如止水 | Attack | Instant/PreMove | - | 冻结敌方,跳过对方下一回合(仍可用反击卡) | 可被 水滴石穿(1010) | | 嗯嗯,嗯 | +| 1003 | 力拔山兮 | Attack/Legendary | Instant/PreMove | - | 直接胜利 | 可被 两极反转(1011)、东山再起(1012) | | 这可是我的最终奥义 | +| 1004 | 我是保洁 | Attack/Fusion | Instant/PreMove | 需张兴朝在场 | 随机扫走敌我各一枚棋子至什刹海;目标落点下回合封禁 | 可被 滚!(1013) | 张兴朝离场或被“滚!”打断 | 我这招叫做保洁上门 | +| 1005 | 这是棒球 | Attack/Standard | Normal/PreMove | 需张兴朝在场 | 等同“飞沙走石” | 免疫:擒拿/滚!;可被拾金不昧挽回 | 只能被拾金不昧挽回 | 呀咧呀咧 | +| 1006 | 时光倒流 | Attack/Legendary | Instant/PreMove | - | 撤回双方最近一手(不恢复手牌/墓地/牌堆) | | | 这一招好 | +| 1007 | 调呈离山·东 | Attack/Fusion | Instant/PreMove | 需张兴朝在场 | 跳过对方下一回合 | 可被 滚!(1013) | 张兴朝离场或被“滚!”打断 | 就是调走张呈,离开山东! | + +- 反击类(Counter) + +| tid | 名称 | 类型 | 速度/时机 | 触发 | 主要效果 | 备注/依赖 | 台词 | +|---|---|---|---|---|---|---|---| +| 1008 | 拾金不昧 | Counter/Reactive | Normal/Reaction | 己方棋子被移除 | 将该棋子放回任意空位(先手不转移) | | 嘿,拾金不昧 | +| 1009 | 擒拿 | Counter/Reactive | Instant/Reaction | 敌方使用“飞沙走石” | 阻止移除(blocks=1001) | | 我只是受过九年制义务教育 | +| 1010 | 水滴石穿 | Counter/Reactive | Instant/Reaction | 己方被冻结 | 解除冻结,不失去行动权 | | 水滴石穿 | +| 1011 | 两极反转 | Counter/Legendary | Instant/Reaction | 敌方发动力拔山兮 | 直接反夺胜利 | 需张兴朝在场 | 哈哈哈哈哈哈,两极反转 | +| 1012 | 东山再起 | Counter/Reactive | Instant/Reaction | 力拔山兮结算后 | 强制恢复继续对局,并立刻进入我方回合 | | 那我就,东山再起 | +| 1013 | 滚! | Counter/Fusion | Instant/Reaction | 敌方发动合体技 | 阻止其效果 | 可能被“你骂老人”反击;需张兴朝在场 | 诶, 怎么又走了 | +| 1014 | 你骂老人 | Counter/Legendary | Instant/Reaction | 己方合体技被“滚!”阻断时 | 令对手下回合无法行动 | | 你骂老人! | + +- 辅助类(Support) + +| tid | 名称 | 类型 | 速度/时机 | 主要效果 | 备注 | 台词 | +|---|---|---|---|---|---|---| +| 1015 | 技能五 | Support | Instant/Anytime | 召唤“张兴朝”入场,使合体技可发动 | 召唤当回合无法立刻发动合体技 | 技能五子·张技能五 | +| 1016 | See You Again | Support | Instant/PreMove | 强制让对方“张兴朝”退场(target=3001) | | 胜天半子·王金宝 | ## 四、弹窗系统 @@ -98,14 +130,14 @@ |---|---| | 反击窗口(Counter Window)| 当对手发动技能时弹出:左侧显示对方技能动画,右侧显示自己可用的反击卡列表;倒计时 5 秒决定是否反击。 | | 墓地浏览弹窗| 展示墓地中的卡牌列表,按使用顺序排列,可点击查看详情。 | -| 什刹海记录弹窗| 展示被移除棋子的编号、坐标、以及当时棋盘快照(小缩略图)。 | +| 什刹海记录弹窗| 展示被移除棋子的编号、坐标、以及当时棋盘快照(小缩略图)。(已在右侧面板合并为“什刹海(合并)”,位于棋谱纪要下方) | | 胜负结算界面| 显示胜利动画(力拔山兮破碎棋盘 or 东山再起复苏棋盘)。 | ## 五、动画与特效建议 | 场景| 动画说明 | |---|---| -| 飞沙走石| 一阵沙尘直奔目标棋子,卷起目标棋子飞离棋盘,飞入对方什刹海 | +| 飞沙走石| 一阵沙尘直奔目标棋子,卷起目标棋子飞离棋盘,飞入对方什刹海(自身放弃本回合落子;目标落点下回合封禁) | | 力拔山兮| 整个棋盘震动、裂开、高高掀起然后反转后重重抛向地面 | | 静如止水| 对方任务剧烈摇晃,虽然后淡蓝冰块爬上任务头像,棋子冻结 | | 时光倒流| 棋盘快速回滚动画(悔棋),从最后两手依次淡出撤销;不恢复手牌/墓地/牌堆 | @@ -191,7 +223,7 @@ #### 7. Bug 修复 -- **棒球反击免疫**:检查技能参数中的 `ignoreSeize` 标记,防止带有此标记的技能(如棒球)被"拾金不昧"反击 +- **棒球反击免疫**:检查技能参数中的 `ignoreSeize`/`immuneShout`,防止带有此标记的技能(如“这是棒球”)被“擒拿”“滚!”防御;只能被“拾金不昧”在生效后挽回 - **飞沙走石回合跳过**:移除错误的 skip 计数器递增,确保只跳过当前回合 - **东山再起快照**:在技能 prepare 阶段创建 `preWinSnapshot`,确保反击时可以正确恢复棋盘状态 @@ -258,7 +290,7 @@ const remainingTurns = isSealed ? sealedCells[sealedForPlayer]!.expiresAtTurn - - ✅ 封禁格子显示禁止图标和剩余回合数 - ✅ 墓地/什刹海空状态显示友好提示 - ✅ 界面高度不超出浏览器视口 -- ✅ 棒球免疫"拾金不昧"反击 +- ✅ 棒球免疫“擒拿”“滚!”;可被“拾金不昧”挽回 - ✅ 飞沙走石只跳过当前回合 - ✅ 东山再起可正常恢复棋盘 - ✅ 棋谱右上角显示回合信息 diff --git a/example/game_07_skill_gomoku/cards.csv b/example/game_07_skill_gomoku/cards.csv index f1955d4..00137b9 100644 --- a/example/game_07_skill_gomoku/cards.csv +++ b/example/game_07_skill_gomoku/cards.csv @@ -1,18 +1,24 @@ -@,enum,enum?,string,string,enum,uint,enum,enum,string,string?,enum,string?,uint[]?,uint?,uint[]?,string?,string?,string?,string,string -tid,type,subtype,nameZh,nameEn,rarity,cost,speed,timing,effect,triggerCondition,effectId,effectParams,counteredBy,requiresCharacter,requiresCards,failCondition,tags,notes,quote,artwork -1001,Attack,Standard,飞沙走石,Sandstorm,Common,1,Normal,PreMove,指定敌方棋子移出棋盘,进入什刹海;目标落点下回合被封禁,同时自己放弃本回合落子。,,RemoveToShichahai,,1008|1009,,,被擒拿或拾金不昧打断时取消,Removal|Shichahai,,你管这个叫五子棋?, -1002,Attack,Standard,静如止水,Time Freeze,Rare,2,Instant,PreMove,冻结敌方,跳过对方下一轮落子或发动魔法卡 (反击卡不受影响)。,,FreezeOpponent,turns=1,1010,,,冻结期间仍可使用反击卡,Attack|Freeze,,嗯嗯,嗯, -1003,Attack,Legendary,力拔山兮,Instant Win,Legendary,3,Instant,PreMove,直接摔坏棋盘并宣布胜利。,,InstantWin,,1011|1012,,,若被反制则视作发动失败,DirectWin|Legendary,,这可是我的最终奥义, -1004,Attack,Fusion,我是保洁,Cleaner Service,Rare,1,Instant,PreMove,同时随机扫走敌我各一枚棋子,移入什刹海。,己方张兴朝在场,CleanSweep,,1013,3001,,张兴朝离场或被滚!打断,Fusion|Removal|Random|Shichahai,,我这招叫做保洁上门, -1005,Attack,Standard,这是棒球,Baseball Strike,Rare,1,Normal,PreMove,效果等同于飞沙走石(含封禁落点与自我跳过落子),但视为合体技,无法被擒拿或“滚!”阻断;拾金不昧仍可事后挽回棋子。,,RemoveToShichahai,"ignoreSeize=true,immuneShout=true",1008,3001,,只能被拾金不昧挽回,Attack|Fusion|Removal|Shichahai|Unseizeable,,呀咧呀咧, -1006,Attack,Legendary,时光倒流,Time Rewind,Legendary,3,Instant,PreMove,移除双方最近一轮的落子(各移除一手;若某方无落子则忽略),不还原已使用的卡牌。,,TimeRewind,,1013,,,悔棋效果:不恢复手牌/墓地/牌堆,Attack|Reset|Rewind,,这一招好, -1007,Attack,Fusion,调呈离山·东,Distract,Rare,2,Instant,PreMove,跳过对手 1 回合,张兴朝仍然留场助战。,,SkipNextTurn,turns=1,1013,3001,,张兴朝离场或被滚!打断,Fusion|Attack|SkipTurn,,就是调走张呈,离开山东!, -1008,Counter,Reactive,拾金不昧,Item Retrieval,Common,1,Normal,Reaction,当己方棋子被移除时,改为放回任意空位。,敌方使用移除类技能时,CounterRetrieve,,,,,Counter|Recovery|Shichahai,,嘿,拾金不昧, -1009,Counter,Reactive,擒拿,Seize,Common,1,Instant,Reaction,阻止飞沙走石发动,使目标棋子保留原位。,敌方使用飞沙走石时,CounterPreventRemoval,blocks=1001,,,,Counter|Prevention,,我只是受过九年制义务教育, -1010,Counter,Reactive,水滴石穿,Break Free,Common,1,Instant,Reaction,解除静如止水效果,立即恢复行动权。,己方被冻结时,CounterThaw,,,,,Counter|Recovery|Freeze,,水滴石穿, -1011,Counter,Legendary,两极反转,Reverse Trap,Legendary,2,Instant,Reaction,敌方发动力拔山兮时发动,直接反夺胜利。,敌方发动力拔山兮时,CounterReverseWin,,,3001,,需要张兴朝见证,Fusion|Counter|Legendary|DirectWin|Reflect,,哈哈哈哈哈哈,两极反转, -1012,Counter,Reactive,东山再起,Resurgence,Rare,2,Instant,Reaction,力拔山兮生效后恢复棋盘至破坏前状态并继续对局,同时令对手跳过下一个回合。,力拔山兮结算后,CounterRestoreBoard,,,,,Counter|Recovery|Reset,,那我就,东山再起, -1013,Counter,Fusion,滚!,Shout,Rare,1,Instant,Reaction,对手发动任意合体技时阻止其效果。,敌方发动合体技时,CounterCancelFusion,,1014,3001,,需保持士气以阻断合体技,Fusion|Counter|AntiFusion,,诶, 怎么又走了, -1014,Counter,Legendary,你骂老人,Moral Pressure,Legendary,2,Instant,Reaction,当己方合体技被滚!打断时触发,对手下回合无法行动。,敌方使用滚!阻断己方合体技时,CounterPunish,,,,,Counter|Punish,,你骂老人!, -1015,Support,Support,技能五,Summon Skill Five,Common,1,Instant,Anytime,召唤张兴朝入场,使合体技可以发动。,,SummonCharacter,character=3001,,,召唤当回合无法立刻发动合体技,Support|Summon|Fusion,,技能五子·张技能五, -1016,Support,Support,See You Again,Force Exit,Common,1,Instant,PreMove,强制让对方张兴朝退场;若无目标则作废。,,ForceExit,target=3001,,,,Support|AntiFusion,,胜天半子·王金宝, +@,enum,enum?,string,string,enum,uint,enum,enum,string,string?,enum,string?,uint[]?,uint?,uint[]?,string?,string?,string?,string,string, +tid,type,subtype,nameZh,nameEn,rarity,cost,speed,timing,effect,triggerCondition,effectId,effectParams,counteredBy,requiresCharacter,requiresCards,failCondition,tags,notes,quote,artwork, +1001,Attack,Standard,飞沙走石,Sandstorm,Common,1,Normal,PreMove,指定敌方棋子移出棋盘,进入什刹海;目标落点下回合被封禁,同时自己放弃本回合落子。,,RemoveToShichahai,,1008|1009,,,被擒拿或拾金不昧打断时取消,Removal|Shichahai,,你管这个叫五子棋?,, +1002,Attack,Standard,静如止水,Time Freeze,Rare,2,Instant,PreMove,冻结敌方,跳过对方下一回合 (无法落子和释放技能),可以被「水滴石穿」破解,,FreezeOpponent,turns=1,1010,,,冻结期间仍可使用反击卡,Attack|Freeze,,嗯嗯,嗯,, +1003,Attack,Legendary,力拔山兮,Instant Win,Legendary,3,Instant,PreMove,直接摔坏棋盘并宣布胜利,可以被 「东山再起」和「两级反转」破解,,InstantWin,,1011|1012,,,若被反制则视作发动失败,DirectWin|Legendary,,这可是我的最终奥义,, +1004,Attack,Fusion,我是保洁,Cleaner Service,Rare,1,Instant,PreMove,同时随机扫走敌我各一枚棋子,移出棋盘,进入什刹海;目标落点下回合被封禁,己方张兴朝在场,CleanSweep,,1013,3001,,张兴朝离场或被滚!打断,Fusion|Removal|Random|Shichahai,,我这招叫做保洁上门,, +1005,Attack,Standard,这是棒球,Baseball Strike,Rare,1,Normal,PreMove,合体技,效果等同于「飞沙走石」,无法被「擒拿」或「滚!」 防御,,RemoveToShichahai,"ignoreSeize=true,immuneShout=true",1008,3001,,只能被拾金不昧挽回,Attack|Fusion|Removal|Shichahai|Unseizeable,,呀咧呀咧,, +1006,Attack,Legendary,时光倒流,Time Rewind,Legendary,3,Instant,PreMove,移除双方最近一手的落子,,TimeRewind,,1013,,,悔棋效果:不恢复手牌/墓地/牌堆,Attack|Reset|Rewind,,这一招好,, +1007,Attack,Fusion,调呈离山·东,Distract,Rare,2,Instant,PreMove,合体技,跳过对方下一回合 (无法落子和释放技能),,SkipNextTurn,turns=1,1013,3001,,张兴朝离场或被滚!打断,Fusion|Attack|SkipTurn,,就是调走张呈,离开山东!,, +1008,Counter,Reactive,拾金不昧,Item Retrieval,Common,1,Normal,Reaction,当己方棋子被移除时可以触发,棋子可以放回场上任意指定空位;但先手不会转移,敌方使用移除类技能时,CounterRetrieve,,,,,Counter|Recovery|Shichahai,,嘿,拾金不昧,,, +1009,Counter,Reactive,擒拿,Seize,Common,1,Instant,Reaction,「飞沙走石」发动时可以触发,棋子不会被移走,敌方使用飞沙走石时,CounterPreventRemoval,blocks=1001,,,,Counter|Prevention,,我只是受过九年制义务教育,,, +1010,Counter,Reactive,水滴石穿,Break Free,Common,1,Instant,Reaction,「静如止水」发动时可以触发,不会失去行动权,己方被冻结时,CounterThaw,,,,,Counter|Recovery|Freeze,,水滴石穿,,, +1011,Counter,Legendary,两极反转,Reverse Trap,Legendary,2,Instant,Reaction,敌方发动「力拔山兮」时发动,直接反夺胜利,敌方发动力拔山兮时,CounterReverseWin,,,3001,,需要张兴朝见证,Fusion|Counter|Legendary|DirectWin|Reflect,,哈哈哈哈哈哈,两极反转,, +1012,Counter,Reactive,东山再起,Resurgence,Rare,2,Instant,Reaction,「力拔山兮」生效后发送,强制恢复继续对局,并立刻进入我方回合,力拔山兮结算后,CounterRestoreBoard,,,,,Counter|Recovery|Reset,,那我就,东山再起,,, +1013,Counter,Fusion,滚!,Shout,Rare,1,Instant,Reaction,对手发动任意合体技时可以发动,阻止其效果,但可能被「你骂老人」反击从而冻结,敌方发动合体技时,CounterCancelFusion,,1014,3001,,需保持士气以阻断合体技,Fusion|Counter|AntiFusion,,诶," 怎么又走了", +1014,Counter,Legendary,你骂老人,Moral Pressure,Legendary,2,Instant,Reaction,当己方合体技被「滚!」打断时触发,令对手下回合无法行动,敌方使用滚!阻断己方合体技时,CounterPunish,,,,,Counter|Punish,,你骂老人!,,, +1015,Support,Support,技能五,Summon Skill Five,Common,1,Instant,Anytime,"召唤角色 ""张兴朝"" 入场,使合体技可以发动",,SummonCharacter,character=3001,,,召唤当回合无法立刻发动合体技,Support|Summon|Fusion,,技能五子·张技能五,,, +1016,Support,Support,See You Again,Force Exit,Common,1,Instant,PreMove,使用手刀强制让对方张兴朝退场,,ForceExit,target=3001,,,,Support|AntiFusion,,胜天半子·王金宝,,, +,,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,, diff --git a/example/game_07_skill_gomoku/ui/public/cards.json b/example/game_07_skill_gomoku/ui/public/cards.json index e601fe7..a7324c3 100644 --- a/example/game_07_skill_gomoku/ui/public/cards.json +++ b/example/game_07_skill_gomoku/ui/public/cards.json @@ -51,7 +51,7 @@ "cost": 2, "speed": "instant", "timing": "pre-move", - "effect": "冻结敌方,跳过对方下一轮落子或发动魔法卡 (反击卡不受影响)。", + "effect": "冻结敌方,跳过对方下一回合 (无法落子和释放技能),可以被「水滴石穿」破解", "triggerCondition": "", "effectId": "freeze-opponent", "effectParams": "turns=1", @@ -73,7 +73,7 @@ "cost": 3, "speed": "instant", "timing": "pre-move", - "effect": "直接摔坏棋盘并宣布胜利。", + "effect": "直接摔坏棋盘并宣布胜利,可以被 「东山再起」和「两级反转」破解", "triggerCondition": "", "effectId": "instant-win", "effectParams": "", @@ -95,7 +95,7 @@ "cost": 1, "speed": "instant", "timing": "pre-move", - "effect": "同时随机扫走敌我各一枚棋子,移入什刹海。", + "effect": "同时随机扫走敌我各一枚棋子,移出棋盘,进入什刹海;目标落点下回合被封禁", "triggerCondition": "己方张兴朝在场", "effectId": "clean-sweep", "effectParams": "", @@ -118,7 +118,7 @@ "cost": 1, "speed": "normal", "timing": "pre-move", - "effect": "效果等同于飞沙走石(含封禁落点与自我跳过落子),但视为合体技,无法被擒拿或“滚!”阻断;拾金不昧仍可事后挽回棋子。", + "effect": "合体技,效果等同于「飞沙走石」,无法被「擒拿」或「滚!」 防御", "triggerCondition": "", "effectId": "remove-to-shichahai", "effectParams": "ignoreSeize=true,immuneShout=true", @@ -141,7 +141,7 @@ "cost": 3, "speed": "instant", "timing": "pre-move", - "effect": "移除双方最近一轮的落子(各移除一手;若某方无落子则忽略),不还原已使用的卡牌。", + "effect": "移除双方最近一手的落子", "triggerCondition": "", "effectId": "time-rewind", "effectParams": "", @@ -163,7 +163,7 @@ "cost": 2, "speed": "instant", "timing": "pre-move", - "effect": "跳过对手 1 回合,张兴朝仍然留场助战。", + "effect": "合体技,跳过对方下一回合 (无法落子和释放技能)", "triggerCondition": "", "effectId": "skip-next-turn", "effectParams": "turns=1", @@ -186,7 +186,7 @@ "cost": 1, "speed": "normal", "timing": "reaction", - "effect": "当己方棋子被移除时,改为放回任意空位。", + "effect": "当己方棋子被移除时可以触发,棋子可以放回场上任意指定空位;但先手不会转移", "triggerCondition": "敌方使用移除类技能时", "effectId": "counter-retrieve", "effectParams": "", @@ -207,7 +207,7 @@ "cost": 1, "speed": "instant", "timing": "reaction", - "effect": "阻止飞沙走石发动,使目标棋子保留原位。", + "effect": "「飞沙走石」发动时可以触发,棋子不会被移走", "triggerCondition": "敌方使用飞沙走石时", "effectId": "counter-prevent-removal", "effectParams": "blocks=1001", @@ -228,7 +228,7 @@ "cost": 1, "speed": "instant", "timing": "reaction", - "effect": "解除静如止水效果,立即恢复行动权。", + "effect": "「静如止水」发动时可以触发,不会失去行动权", "triggerCondition": "己方被冻结时", "effectId": "counter-thaw", "effectParams": "", @@ -249,7 +249,7 @@ "cost": 2, "speed": "instant", "timing": "reaction", - "effect": "敌方发动力拔山兮时发动,直接反夺胜利。", + "effect": "敌方发动「力拔山兮」时发动,直接反夺胜利", "triggerCondition": "敌方发动力拔山兮时", "effectId": "counter-reverse-win", "effectParams": "", @@ -271,7 +271,7 @@ "cost": 2, "speed": "instant", "timing": "reaction", - "effect": "力拔山兮生效后恢复棋盘至破坏前状态并继续对局,同时令对手跳过下一个回合。", + "effect": "「力拔山兮」生效后发送,强制恢复继续对局,并立刻进入我方回合", "triggerCondition": "力拔山兮结算后", "effectId": "counter-restore-board", "effectParams": "", @@ -292,7 +292,7 @@ "cost": 1, "speed": "instant", "timing": "reaction", - "effect": "对手发动任意合体技时阻止其效果。", + "effect": "对手发动任意合体技时可以发动,阻止其效果,但可能被「你骂老人」反击从而冻结", "triggerCondition": "敌方发动合体技时", "effectId": "counter-cancel-fusion", "effectParams": "", @@ -315,7 +315,7 @@ "cost": 2, "speed": "instant", "timing": "reaction", - "effect": "当己方合体技被滚!打断时触发,对手下回合无法行动。", + "effect": "当己方合体技被「滚!」打断时触发,令对手下回合无法行动", "triggerCondition": "敌方使用滚!阻断己方合体技时", "effectId": "counter-punish", "effectParams": "", @@ -336,7 +336,7 @@ "cost": 1, "speed": "instant", "timing": "anytime", - "effect": "召唤张兴朝入场,使合体技可以发动。", + "effect": "召唤角色 \"张兴朝\" 入场,使合体技可以发动", "triggerCondition": "", "effectId": "summon-character", "effectParams": "character=3001", @@ -358,7 +358,7 @@ "cost": 1, "speed": "instant", "timing": "pre-move", - "effect": "强制让对方张兴朝退场;若无目标则作废。", + "effect": "使用手刀强制让对方张兴朝退场", "triggerCondition": "", "effectId": "force-exit", "effectParams": "target=3001", diff --git a/example/game_07_skill_gomoku/ui/src/App.tsx b/example/game_07_skill_gomoku/ui/src/App.tsx index 1e9fc23..7a36f2f 100644 --- a/example/game_07_skill_gomoku/ui/src/App.tsx +++ b/example/game_07_skill_gomoku/ui/src/App.tsx @@ -5,6 +5,7 @@ import { PendingCardPanel, SnapshotSelector, ZonePanel, + CombinedShichahaiPanel, OpponentHUD, PlayerHUD, SkillEffectLayer, @@ -14,7 +15,8 @@ import { GameOverPanel, RulesIntroPanel, CardFlyingAnimation, - MusicToggle + CardCollectionPanel, + } from './components'; import type { FlyingCardData } from './components'; import { useGameEngine } from './core/gameEngine'; @@ -34,6 +36,11 @@ import { useVisualEffects } from './app/hooks/useVisualEffects'; import { useAutoResolvePending } from './app/hooks/useAutoResolvePending'; import { useWinPresentation } from './app/hooks/useWinPresentation'; import { useInactivityPrompt } from './app/hooks/useInactivityPrompt'; +import { ToastContainer } from './components/Toast'; +import { MobileBlocker } from './app/components/MobileBlocker'; +import { MusicToggle } from './app/components/MusicToggle'; +import { findReplayParam, decodeReplay } from './app/utils/replay'; +import { ReplayPlayer } from './replay/ReplayPlayer'; // Utility for conditional classnames const cx = (...classes: Array) => classes.filter(Boolean).join(' '); @@ -46,6 +53,12 @@ if (import.meta.env.DEV) { // 数据加载已抽离到 app/utils/data.ts,并做了基本的类型守卫 +// ===== Board Size Tuning ===== +// 直接通过常量调整棋盘相对大小,而不是去改 tailwind 类名。 +// 说明:棋盘容器的最大宽度为 min(BOARD_MAX_VH vh, BOARD_MAX_VW vw)。若想让棋盘“更小一些”,可以把这两个数值下调(例如 62/66 -> 60/64)。 +export const BOARD_MAX_VH = 64; // 以视口高度为基准的上限(vh) +export const BOARD_MAX_VW = 68; // 以视口宽度为基准的上限(vw) + const App: React.FC = () => { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -60,12 +73,19 @@ const App: React.FC = () => { message: '' }); const [hoveredPosition, setHoveredPosition] = useState<{ row: number; col: number } | null>(null); - const [skillTargetPosition, setSkillTargetPosition] = useState<{ row: number; col: number } | null>(null); // 技能目标位置高亮 + const [skillTargetPosition, setSkillTargetPosition] = useState<{ row: number; col: number } | null>(null); + const [isCardCollectionOpen, setIsCardCollectionOpen] = useState(false); const winAnimTimersRef = useRef([]); + const recordedEventsRef = useRef([]); + const recordedVisualIdsRef = useRef>(new Set()); + const [replayFrames, setReplayFrames] = useState(null); // 背景音乐(默认开启,浏览器如拦截则在用户交互后恢复) const bgm = useBackgroundMusic(); const sfx = useSoundEffects(); const playedSfxRef = useRef>(new Set()); + const prevHandCountsRef = useRef<[number, number]>([0, 0]); + // 出牌音效:记录最近一次 pendingAction id + const lastPendingIdRef = useRef(null); // 落子音效:检测棋谱长度变化(在 engine 初始化之后挂载) const lastHistoryLenRef = useRef(0); // AI 互动:头像气泡 & 玩家久未行动跟踪 @@ -77,6 +97,37 @@ const App: React.FC = () => { const aiVictoryShownRef = useRef(false); // 飞行卡牌动画状态 const [flyingCards, setFlyingCards] = useState([]); + // Center toasts for error/info + const [toasts, setToasts] = useState>([]); + const pushToast = useCallback((message: string, kind: 'info' | 'error' | 'warn' = 'info') => { + setToasts(prev => [...prev, { id: `toast-${Date.now()}-${Math.random().toString(36).slice(2,6)}`, message, kind }].slice(-4)); + }, []); + const removeToast = useCallback((id: string) => { + setToasts(prev => prev.filter(t => t.id !== id)); + }, []); + // 设备类型:阻挡手机,仅在触控平板上允许点击释放 + const [isPhone, setIsPhone] = useState(false); + const [isPad, setIsPad] = useState(false); + useEffect(() => { + const calc = () => { + const coarse = typeof window !== 'undefined' && typeof matchMedia !== 'undefined' && window.matchMedia('(pointer: coarse)').matches; + const width = typeof window !== 'undefined' ? window.innerWidth : 1024; + setIsPhone(!!coarse && width < 768); + setIsPad(!!coarse && width >= 768); + }; + calc(); + window.addEventListener('resize', calc); + return () => window.removeEventListener('resize', calc); + }, []); + + // When blocking on phone, prevent page scroll behind overlay + useEffect(() => { + if (isPhone) { + const prevOverflow = document.documentElement.style.overflow; + document.documentElement.style.overflow = 'hidden'; + return () => { document.documentElement.style.overflow = prevOverflow; }; + } + }, [isPhone]); const opponentHandRef = useRef(null); const playerHandRef = useRef(null); const draftPanelRef = useRef(null); @@ -110,21 +161,46 @@ const App: React.FC = () => { }); }, []); + // 解析回放链接,进入回放播放器(支持帧和事件) + useEffect(() => { + const raw = findReplayParam(); + if (!raw) return; + const decoded = decodeReplay(raw); + if (!decoded) return; + try { + const frames = (decoded.timeline ?? []).map((t: any) => t.board).filter(Boolean); + const events = (decoded as any).events; + if ((Array.isArray(frames) && frames.length > 0) || (Array.isArray(events) && events.length > 0)) { + // 为兼容旧播放器保留 replayFrames,但优先在渲染处传事件 + if (Array.isArray(frames) && frames.length > 0) setReplayFrames(frames); + // 临时把事件挂到 window 以便 ReplayPlayer 读取(简单兼容处理) + (window as any).__REPLAY_EVENTS__ = Array.isArray(events) ? events : undefined; + (window as any).__REPLAY_PLAYERS__ = (decoded as any).players || undefined; + } + } catch {} + }, []); + const engine = useGameEngine(data ?? {}); const { gameState, startGame, placeStone, playCard, selectTarget, resolveCard, cancelPending, selectDraftOption, advanceTurnIfBlocked } = engine; const isPlayerTurn = gameState.currentPlayer === PlayerEnum.BLACK; // 基于 gameState 的副作用 hooks(必须在 engine 初始化之后调用) const activeVisuals = useVisualEffects(gameState); const visualsActive = activeVisuals.length > 0; - // 落子音效:检测棋谱长度变化 + // 落子音效:检测棋谱长度变化 + 录制落子事件 useEffect(() => { const len = gameState.board.history.length; if (len > lastHistoryLenRef.current) { try { sfx.playPlace(); } catch {} + try { + const last = (gameState.board as any).history?.[len - 1]; + if (last && typeof last.row === 'number' && typeof last.col === 'number' && typeof last.player !== 'undefined') { + recordedEventsRef.current.push({ t: 'p', r: last.row, c: last.col, pl: last.player, at: Date.now() }); + } + } catch {} } lastHistoryLenRef.current = len; }, [gameState.board.history.length]); - // 在视觉事件发生时播放简单效果音(可在设置里关闭/调节) + // 在视觉事件发生时播放简单效果音(可在设置里关闭/调节)+ 录制视觉事件 useEffect(() => { activeVisuals.forEach(ev => { if (!playedSfxRef.current.has(ev.id)) { @@ -134,9 +210,56 @@ const App: React.FC = () => { const first = playedSfxRef.current.values().next().value as string | undefined; if (first) playedSfxRef.current.delete(first); } + if (!recordedVisualIdsRef.current.has(ev.id)) { + recordedVisualIdsRef.current.add(ev.id); + const keep = (({ id, effectId, cardName, cardTid, cardQuote, player, role, cell, owner }) => ({ id, effectId, cardName, cardTid, cardQuote, player, role, cell, owner }))(ev as any); + recordedEventsRef.current.push({ t: 'v', ev: keep, at: Date.now() }); + } } }); }, [activeVisuals, sfx]); + + // 将关键 error 日志以 center toast 的形式显著提示 + const lastLogLenRef = useRef(0); + useEffect(() => { + const len = gameState.logs.length; + if (len > lastLogLenRef.current) { + const recent = gameState.logs.slice(lastLogLenRef.current); + recent.forEach(entry => { + if (entry.type === 'error') { + pushToast(entry.message, 'error'); + } + }); + lastLogLenRef.current = len; + } + }, [gameState.logs, pushToast]); + + // 抽卡音效:当任意一方手牌数量增加时播一次提示音 + useEffect(() => { + const curr: [number, number] = [ + (gameState.hands[PlayerEnum.BLACK] ?? []).length, + (gameState.hands[PlayerEnum.WHITE] ?? []).length + ]; + const prev = prevHandCountsRef.current; + if (curr[0] > prev[0]) { try { sfx.playEffect('draw-card', 'attacker'); } catch {} } + if (curr[1] > prev[1]) { try { sfx.playEffect('draw-card', 'counter'); } catch {} } + prevHandCountsRef.current = curr; + }, [gameState.hands[PlayerEnum.BLACK]?.length, gameState.hands[PlayerEnum.WHITE]?.length]); + + // 出牌音效:当 pendingAction 创建(进入技能释放流程)时即刻播放 + useEffect(() => { + const id = gameState.pendingAction?.id ?? null; + if (id && id !== lastPendingIdRef.current) { + const role = gameState.pendingAction?.player === PlayerEnum.BLACK ? 'attacker' : 'counter'; + try { sfx.playEffect('play-card', role as any); } catch {} + try { + const pa = gameState.pendingAction!; + const card = pa.card; + recordedEventsRef.current.push({ t: 'play', pl: pa.player, card: { tid: (card as any)._tid ?? (card as any).tid, name: card.nameZh ?? card.nameEn }, at: Date.now() }); + } catch {} + } + lastPendingIdRef.current = id; + }, [gameState.pendingAction?.id]); const { showFinalPanel, winLineLit } = useWinPresentation(gameState); useAutoResolvePending(gameState, resolveCard); // 玩家久未行动提示(气泡 + 棋盘轻晃) @@ -170,12 +293,12 @@ const App: React.FC = () => { useAIActions(gameState, { playCard, placeStone, resolveCard, selectTarget, advanceTurnIfBlocked }, aiSettings, updateAiStatus, visualsActive); - // 进入 PLAYING 阶段即尝试播放 BGM(兼容非 AI 开局) + // 进入 PLAYING 阶段仅在用户开启 BGM 时尝试播放(避免技能结算返回 PLAYING 时误触发) useEffect(() => { - if (gameState.phase === GamePhaseEnum.PLAYING && !bgm.isPlaying) { + if (gameState.phase === GamePhaseEnum.PLAYING && !bgm.isPlaying && bgm.enabled) { try { bgm.play(); } catch {} } - }, [gameState.phase]); + }, [gameState.phase, bgm.isPlaying, bgm.enabled]); useEffect(() => { if (typeof window === 'undefined') return; @@ -222,10 +345,18 @@ const App: React.FC = () => { return ; }; + // 回放播放器优先显示(即使资源还在加载时) + if (replayFrames || (window as any).__REPLAY_EVENTS__) { + return ( + { setReplayFrames(null); (window as any).__REPLAY_EVENTS__ = null; }} /> + ); + } + if (isLoading) { return ( <>
加载配置中…
+ {isPhone && } { return ( <>
{error}
+ {isPhone && } { const { phase, board, currentPlayer, hands, logs, pendingAction, targetRequest, aiEnabled, moveCount, statuses, winner } = gameState; + // 回放播放器优先显示(常规分支) + if (replayFrames || (window as any).__REPLAY_EVENTS__) { + return ( + { setReplayFrames(null); (window as any).__REPLAY_EVENTS__ = null; }} /> + ); + } + if (phase === GamePhaseEnum.SETUP) { return ( <> -
- +
+ {isPhone && } + setIsCardCollectionOpen(true)} /> { @@ -268,7 +408,8 @@ const App: React.FC = () => { setAiBubble({ id, text: '未配置云端 AI,将使用本地策略' }); window.setTimeout(() => setAiBubble(prev => (prev && prev.id === id ? null : prev)), 1600); } - try { bgm.play(); } catch {} + // 仅在用户偏好开启时开播 BGM + try { if (bgm.enabled) bgm.play(); } catch {} startGame(true); }} /> @@ -279,6 +420,7 @@ const App: React.FC = () => { onClose={() => setIsSettingsOpen(false)} onSave={setAiSettings} /> + setIsCardCollectionOpen(false)} /> ); } @@ -315,6 +457,7 @@ const App: React.FC = () => { return ( <> +
{ backgroundPosition: 'center center', backgroundSize: '100% auto' }}> - + setIsCardCollectionOpen(true)} + /> {/* Backdrop decorative layers */}
{ width: 'min(1600px, 98vw)', height: '100vh', maxHeight: '100vh', - padding: 'clamp(0.6rem, 1vw, 1rem)' + padding: 'clamp(0.4rem, 1vw, 1rem)' }}> -
+
{/* 左列:改为覆盖式布局,棋盘独立垂直居中,HUD 不影响棋盘位置 */}
{/* 棋盘舞台:上下预留 HUD 空间,并略微向上偏移中心 */}
{/* Ambient glow behind board */}
{ }} /> {/* Board container */} -
+
{ if (!visualsActive) placeStone(r,c); }} @@ -378,6 +531,9 @@ const App: React.FC = () => { (targetRequest && targetRequest.type === 'snapshot') } targetRequest={targetRequest && targetRequest.type === 'cell' ? targetRequest : null} + targetInteractive={!( + targetRequest && targetRequest.type === 'cell' && targetRequest.actingPlayer === PlayerEnum.WHITE + )} onTargetSelect={(sel) => { try { lastPlayerActionRef.current = Date.now(); setPlayerInactivityLevel(0); } catch {}; selectTarget(sel); }} className="w-full h-full" style={{ aspectRatio: '1 / 1' }} @@ -423,6 +579,7 @@ const App: React.FC = () => { aiEnabled={aiEnabled} isInstant={Boolean(pendingAction.metadata?.uiInstant)} confirmMs={7000} + phase={phase} onReact={(text) => { const id = `pb-${Date.now()}`; setPlayerBubble({ id, text }); @@ -442,7 +599,7 @@ const App: React.FC = () => { {/* 顶部覆盖:对手 HUD,不影响棋盘几何位置 */}
-
+
{ isCurrent={currentPlayer === PlayerEnum.WHITE} animateThinking={aiEnabled && Boolean(aiStatus.scenario)} bubble={aiBubble} + className="pr-4 md:pr-8" />
{/* 底部覆盖:我方 HUD,不影响棋盘几何位置 */}
-
+
{ graveyardCount={playerGraveyard.length} shichahaiCount={shichahaiByPlayer[PlayerEnum.BLACK].length} onCardHover={handlePlayerCardHover} + onCardClick={(idx) => { + if (!canPlayCard) return; + try { lastPlayerActionRef.current = Date.now(); setPlayerInactivityLevel(0); } catch {} + playCard(idx); + }} onCardDragStart={index => { if (!canPlayCard) return; setDraggedCardIndex(index); @@ -481,20 +644,21 @@ const App: React.FC = () => { isCurrent={currentPlayer === PlayerEnum.BLACK} bubble={playerBubble} align="end" - className="pr-4 md:pr-8" + allowTapPlay={isPad} + className="pr-6 md:pr-12" />
- {/* 右列:对手面板 / 日志 / 玩家面板 */} -
+ {/* 右列:上-对手墓地(矮) / 中-棋谱纪要(高) / 下-什刹海(合并) / 下下-我方墓地(矮) */} +
@@ -506,12 +670,18 @@ const App: React.FC = () => { playerNames={PLAYER_NAMES} />
+
+ +
@@ -523,6 +693,8 @@ const App: React.FC = () => { window.location.reload()} + gameForShare={{ turnCount: gameState.turnCount, timeline: gameState.timeline, shichahai: gameState.shichahai, winner: gameState.winner, events: recordedEventsRef.current }} + playersForShare={{ ai: hasValidSettings(aiSettings) ? 'cloud' : 'local' }} /> )} { onClose={() => setIsSettingsOpen(false)} onSave={setAiSettings} /> + setIsCardCollectionOpen(false)} + /> ); diff --git a/example/game_07_skill_gomoku/ui/src/ai/debug.ts b/example/game_07_skill_gomoku/ui/src/ai/debug.ts index 14a9dfb..6341371 100644 --- a/example/game_07_skill_gomoku/ui/src/ai/debug.ts +++ b/example/game_07_skill_gomoku/ui/src/ai/debug.ts @@ -72,7 +72,7 @@ export function debugAiDecision(gameState: GameStatus) { const whiteHand = gameState.hands?.[PlayerEnum.WHITE] || []; console.log(`\n2. 白方手牌检查 (${whiteHand.length}张):`); whiteHand.forEach((card, index) => { - console.log(` [${index}] ${card.nameZh || card.name} - ${card.effect || '无效果描述'}`); + console.log(` [${index}] ${card.nameZh || card.nameEn || String(card._tid ?? card.tid ?? '未知卡牌')} - ${card.effect || '无效果描述'}`); console.log(` - requiresCharacter: ${card.requiresCharacter || '无'}`); console.log(` - timing: ${card.timing || '无'}`); console.log(` - tags: ${card.tags || '无'}`); @@ -149,4 +149,4 @@ if (typeof window !== 'undefined' && import.meta.env.DEV) { console.log('🔧 AI调试工具已加载到 window.debugAI(gameState)'); } -export default debugAiDecision; \ No newline at end of file +export default debugAiDecision; diff --git a/example/game_07_skill_gomoku/ui/src/ai/decisions.ts b/example/game_07_skill_gomoku/ui/src/ai/decisions.ts index c84289b..acb37b7 100644 --- a/example/game_07_skill_gomoku/ui/src/ai/decisions.ts +++ b/example/game_07_skill_gomoku/ui/src/ai/decisions.ts @@ -20,6 +20,14 @@ export function applyDecision( if (!('kind' in decision)) return { success: false, reason: '非法决策格式' }; if (scenario.kind === 'stone') { + // 防抖:若状态已切到选目标/反击或不在白方回合,拒绝执行 + if ( + state.phase !== 'playing' || + state.currentPlayer !== PlayerEnum.WHITE || + state.pendingAction || state.pendingCounter || state.targetRequest || state.draft + ) { + return { success: false, reason: '状态已变化,忽略落子决策' }; + } const move = resolveMoveFromDecision(decision as Extract, state.board, PlayerEnum.WHITE); if (!move) return { success: false, reason: '未提供有效落子' }; actions.placeStone(move.row, move.col); @@ -27,6 +35,14 @@ export function applyDecision( } if (scenario.kind === 'skill') { + // 防抖:若状态已切到选目标/反击或不在白方回合,拒绝执行 + if ( + state.phase !== 'playing' || + state.currentPlayer !== PlayerEnum.WHITE || + state.pendingAction || state.pendingCounter || state.targetRequest || state.draft + ) { + return { success: false, reason: '状态已变化,忽略技能决策' }; + } const idx = resolveHandIndexForDecision( decision as { handIndex?: number; cardId?: string }, scenario.skills.map((s) => s.card) diff --git a/example/game_07_skill_gomoku/ui/src/ai/fallbacks.ts b/example/game_07_skill_gomoku/ui/src/ai/fallbacks.ts index 6f63eba..a6a5963 100644 --- a/example/game_07_skill_gomoku/ui/src/ai/fallbacks.ts +++ b/example/game_07_skill_gomoku/ui/src/ai/fallbacks.ts @@ -25,6 +25,14 @@ export const fallbackDecision = ( fallbackPlaceStone(state, actions, localSuggestion ?? null); break; case 'skill': + // 仅在白方回合且处于 PLAYING 阶段时才允许回退技能 + if ( + state.phase !== 'playing' || + state.currentPlayer !== PlayerEnum.WHITE || + state.pendingAction || state.pendingCounter || state.targetRequest || state.draft + ) { + break; + } if (scenario.skills.length === 0) { fallbackPlaceStone(state, actions, null); break; } const mySkills = scenario.skills; @@ -69,9 +77,18 @@ export const fallbackDecision = ( if (option) actions.selectTarget({ id: option.id }); } break; - case 'counter_window': + case 'counter_window': { + // 若是我方(BLACK)瞬发技能,AI 放弃反击时不应立刻结算,等待 7s 倒计时结束 + const pending = state.pendingAction; + const isBlackInstant = Boolean(pending && pending.player === PlayerEnum.BLACK && pending.metadata && (pending.metadata as any).uiInstant); + if (isBlackInstant) { + // 交由 useAutoResolvePending 在 7s 后自动结算 + break; + } + if (state.phase !== 'counter_window') { break; } actions.resolveCard(false, null); break; + } } }; diff --git a/example/game_07_skill_gomoku/ui/src/ai/openAiClient.ts b/example/game_07_skill_gomoku/ui/src/ai/openAiClient.ts index 982f9dd..a511fee 100644 --- a/example/game_07_skill_gomoku/ui/src/ai/openAiClient.ts +++ b/example/game_07_skill_gomoku/ui/src/ai/openAiClient.ts @@ -110,7 +110,20 @@ export const hasValidSettings = ( settings: AiSettings | null | undefined, ): settings is AiSettings => { if (!settings) return false; - return Boolean(settings.endpoint && settings.reasoningModel); + // 必须同时配置 endpoint、model 与 apiKey 才认为可用;否则一律走本地策略 + return Boolean(settings.endpoint && settings.reasoningModel && settings.apiKey); +}; + +// Fail fast when offline or missing apiKey; caller can fall back to local strategy. +const ensureOnlineAndApiKey = (settings: AiSettings) => { + try { + if (typeof navigator !== 'undefined' && (navigator as any).onLine === false) { + throw new Error('offline'); + } + } catch {} + if (!settings.apiKey || !settings.apiKey.trim()) { + throw new Error('missing_api_key'); + } }; const selectModelForScenario = ( @@ -128,6 +141,8 @@ export const requestAiDecision = async ( scenario: AiScenario, options?: { feedback?: string }, ): Promise => { + ensureOnlineAndApiKey(settings); + const userContent = buildUserPrompt(scenario, options); const model = selectModelForScenario(settings, scenario.kind); const modelSelectionInfo = { scenario: scenario.kind, model } diff --git a/example/game_07_skill_gomoku/ui/src/ai/smartDecision.ts b/example/game_07_skill_gomoku/ui/src/ai/smartDecision.ts index a0b3e0a..3701df8 100644 --- a/example/game_07_skill_gomoku/ui/src/ai/smartDecision.ts +++ b/example/game_07_skill_gomoku/ui/src/ai/smartDecision.ts @@ -54,7 +54,14 @@ export async function makeSmartAiDecision( const traceId = performanceMonitor.startTrace(scenario.kind); let localSuggestion: LocalMoveSuggestion | null = null; - let routing: RoutingDecision; + let routing: RoutingDecision = { + strategy: 'smart-fallback', + reason: '路由前默认回退策略', + confidence: 0, + expectedLatency: 0, + fallbackStrategy: 'local-only' + }; + let routingTime = 0; let decision: AiDecision | null = null; let source: SmartAiDecisionResult['source'] = 'remote'; let error: string | undefined; @@ -108,7 +115,7 @@ export async function makeSmartAiDecision( } : await smartRouter.makeRoutingDecision(scenario, aiSettings, localSuggestion); - const routingTime = performance.now() - routingStart; + routingTime = performance.now() - routingStart; // 记录路由决策 performanceMonitor.recordRoutingDecision(traceId, routing); @@ -172,7 +179,7 @@ export async function makeSmartAiDecision( routing: routing!, performance: { totalTime, - routingTime: routingTime! + routingTime }, localSuggestion, error @@ -339,4 +346,4 @@ export async function requestSmartAiDecision( }); return result.decision; -} \ No newline at end of file +} diff --git a/example/game_07_skill_gomoku/ui/src/ai/smartRouter.ts b/example/game_07_skill_gomoku/ui/src/ai/smartRouter.ts index f2983f9..ee6aaf0 100644 --- a/example/game_07_skill_gomoku/ui/src/ai/smartRouter.ts +++ b/example/game_07_skill_gomoku/ui/src/ai/smartRouter.ts @@ -10,7 +10,7 @@ import type { AiScenario, AiSettings } from './openAiClient'; import type { LocalMoveSuggestion } from './local'; -import type { GameStatus } from '../types'; +import { PlayerEnum } from '../core/constants'; import { performanceMonitor } from './performance'; import { aiLog } from './logger'; @@ -175,14 +175,15 @@ class SmartRouter { } // 技能卡影响 - const activeSkills = scenario.game?.players?.WHITE?.hand?.length || 0; + const activeSkills = scenario.game?.hands?.[PlayerEnum.WHITE]?.length || 0; if (activeSkills > 3) { factors.push('多技能卡环境'); score += 20; } // 禁手情况 - const hasBans = scenario.game?.players?.WHITE?.bannedCells?.length > 0; + const sealed = scenario.game?.statuses?.sealedCells?.[PlayerEnum.WHITE]; + const hasBans = Boolean(sealed && scenario.game.turnCount < sealed.expiresAtTurn); if (hasBans) { factors.push('存在禁手限制'); score += 15; @@ -513,4 +514,4 @@ if (typeof window !== 'undefined' && import.meta.env.DEV) { router: smartRouter, decide: decideAiStrategy }; -} \ No newline at end of file +} diff --git a/example/game_07_skill_gomoku/ui/src/ai/tests.ts b/example/game_07_skill_gomoku/ui/src/ai/tests.ts index a1aad20..464e75a 100644 --- a/example/game_07_skill_gomoku/ui/src/ai/tests.ts +++ b/example/game_07_skill_gomoku/ui/src/ai/tests.ts @@ -35,10 +35,7 @@ const mockGameState: Partial = { } } } as any, - players: { - WHITE: { hand: [] }, - BLACK: { hand: [] } - } as any, + hands: [[], []], statuses: { sealedCells: { [PlayerEnum.WHITE]: null, [PlayerEnum.BLACK]: null } } as any, @@ -208,4 +205,4 @@ if (typeof window !== 'undefined' && import.meta.env.DEV) { console.log('- window.aiTests.testSmartRouting() // 基础功能测试'); console.log('- window.aiTests.stressTestSmartRouting() // 压力测试'); console.log('- window.aiTests.performance.getPerformanceReport() // 查看性能报告'); -} \ No newline at end of file +} diff --git a/example/game_07_skill_gomoku/ui/src/ai/useAIActions.ts b/example/game_07_skill_gomoku/ui/src/ai/useAIActions.ts index 002fafc..755a4a1 100644 --- a/example/game_07_skill_gomoku/ui/src/ai/useAIActions.ts +++ b/example/game_07_skill_gomoku/ui/src/ai/useAIActions.ts @@ -205,12 +205,19 @@ export const useAIActions = ( cachedLocalSuggestion = localSuggestion ?? null; if (localSuggestion && shouldAutoplaySuggestion(localSuggestion)) { aiLog.info('[local:auto]', formatLocalSuggestion(localSuggestion)); - processedRef.current.add(key); + // 标记当前任务为活跃,真正执行成功后再记为 processed + activeKeyRef.current = key; const pos = { row: localSuggestion.row, col: localSuggestion.col }; + const localDelay = !hasValidSettings(aiSettings) + ? Math.floor(500 + Math.random() * 2500) // 0.5~3s + : 520; timerRef.current = window.setTimeout(() => { + // 若在等待期间出现动画或任务被取消,则不执行 + if (activeKeyRef.current !== key) return; clearRunningTask(); + processedRef.current.add(key); actions.placeStone(pos.row, pos.col); - }, 520); + }, localDelay); return; } } @@ -230,19 +237,37 @@ export const useAIActions = ( attempts += 1; attemptsPerKeyRef.current.set(key, attempts); + const rand = (min: number, max: number) => Math.floor(min + Math.random() * (max - min)); const baseDelay = (() => { + const local = !hasValidSettings(aiSettings); + if (local) { + switch (scenarioPayload.kind) { + case 'stone': + return rand(500, 3000); // 0.5~3s + case 'skill': + return rand(1000, 5000); // 1~5s(模拟犹豫) + case 'card_targeting': + return rand(800, 2000); + case 'counter_window': + return rand(600, 1800); + case 'mulligan': + default: + return rand(300, 1200); + } + } + // 云端:轻量延迟,避免与网络耗时叠加 switch (scenarioPayload.kind) { case 'counter_window': return 600; case 'skill': - return 680; + return 650; case 'stone': - return 520; + return 500; default: - return 750; + return 700; } })(); - const delay = reason ? 600 : baseDelay; + const delay = baseDelay; cycleProgressMessage(scenarioPayload, descriptor.message, reason); aiLog.info( `[${scenarioPayload.kind}][attempt=${attempts}] ${descriptor.startLog}${reason ? `(原因:${reason})` : ''}` @@ -250,6 +275,7 @@ export const useAIActions = ( activeKeyRef.current = key; timerRef.current = window.setTimeout(async () => { + // 若期间出现动画或任务被取消,直接忽略 if (activeKeyRef.current !== key) return; const feedbackMessage = reason; @@ -272,6 +298,8 @@ export const useAIActions = ( const runFallback = () => { aiLog.warn(`[${scenarioPayload.kind}] 默认回退策略`); + // 若在等待期间出现动画或任务被取消,则不执行 + if (activeKeyRef.current !== key) return; fallbackDecision(scenarioPayload, gameState, actions, cachedLocalSuggestion); finalize(); }; @@ -285,6 +313,8 @@ export const useAIActions = ( try { const decision = await requestAiDecision(aiSettings, scenarioPayload, { feedback: feedbackMessage }); aiLog.info('[response_json]', decision); + // 动画期间可能已取消当前任务 + if (activeKeyRef.current !== key) return; const outcome = decision ? applyDecision(decision, scenarioPayload, gameState, actions) : { success: false, reason: '未提供有效决策' }; @@ -342,7 +372,7 @@ export const useAIActions = ( return () => { clearRunningTask(false); }; - }, [gameState, aiSettings, playCard, placeStone, resolveCard, selectTarget, updateAiStatus]); + }, [gameState, aiSettings, playCard, placeStone, resolveCard, selectTarget, updateAiStatus, visualsActive]); useEffect(() => () => { if (timerRef.current !== null) { diff --git a/example/game_07_skill_gomoku/ui/src/app/components/AiSettingsTrigger.tsx b/example/game_07_skill_gomoku/ui/src/app/components/AiSettingsTrigger.tsx index 80cc3de..7efb2e1 100644 --- a/example/game_07_skill_gomoku/ui/src/app/components/AiSettingsTrigger.tsx +++ b/example/game_07_skill_gomoku/ui/src/app/components/AiSettingsTrigger.tsx @@ -1,32 +1,115 @@ import React from 'react'; +import { useBackgroundMusic } from '../hooks/useBackgroundMusic'; +import { useSoundEffects } from '../hooks/useSoundEffects'; +import { IconAdjustmentsHorizontal, IconBook, IconMusic, IconMusicOff, IconVolume, IconVolumeOff } from '@tabler/icons-react'; export interface AiSettingsTriggerProps { onOpen: (open: boolean) => void; hasConfig: boolean; + onOpenCardCollection?: () => void; } /** * Fixed top-right trigger button for opening AI settings dialog. * Replaces previous CSS (.ai-settings__trigger) with Tailwind utilities. */ -export const AiSettingsTrigger: React.FC = ({ onOpen, hasConfig }) => { - const base = 'fixed right-6 top-6 z-20 rounded-full border p-2 backdrop-blur transition ' + - 'shadow-[0_8px_18px_rgba(15,23,42,0.35)] hover:-translate-y-px hover:shadow-[0_12px_24px_rgba(15,23,42,0.45)]'; +export const AiSettingsTrigger: React.FC = ({ onOpen, hasConfig, onOpenCardCollection }) => { + const bgm = useBackgroundMusic(); + const sfx = useSoundEffects(); + + const gameRulesUrl = 'https://khgame.github.io/tables/demos/skill-gomoku'; + const btnBase = 'rounded-full border p-2 backdrop-blur transition shadow-[0_8px_18px_rgba(15,23,42,0.35)] hover:-translate-y-px hover:shadow-[0_12px_24px_rgba(15,23,42,0.45)]'; const ready = 'border-emerald-300/50 bg-emerald-400/20 text-emerald-200 hover:bg-emerald-400/30'; const idle = 'border-amber-300/50 bg-slate-900/80 text-amber-300/80'; + + const labelStyle: React.CSSProperties = { fontSize: '10px', lineHeight: '10px', textShadow: '0 1px 2px rgba(0,0,0,0.9), 0 -1px 2px rgba(0,0,0,0.6)' }; + const Caption: React.FC<{ text: string }> = ({ text }) => ( + {text} + ); + return ( - +
+ {/* Settings */} +
+ +
+ + + {/* Card Collection */} + {onOpenCardCollection && ( +
+ +
+ + )} + + {/* Music toggle */} +
+ +
+ + + {/* SFX toggle */} +
+ +
+ + +
+ +
+ + ); }; diff --git a/example/game_07_skill_gomoku/ui/src/app/components/MobileBlocker.tsx b/example/game_07_skill_gomoku/ui/src/app/components/MobileBlocker.tsx new file mode 100644 index 0000000..b806ce0 --- /dev/null +++ b/example/game_07_skill_gomoku/ui/src/app/components/MobileBlocker.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import standKara from '../../assets/stand_kara.png'; + +export const MobileBlocker: React.FC = () => { + return ( +
{ try { e.preventDefault(); } catch {} }} + > + {/* solid scrim */} +
+ {/* center card */} +
+
+
+ player +
+
暂不支持手机端
+

请使用电脑或平板(Pad)打开本 Demo,以获得完整的拖拽与交互体验。

+
提示:Pad 上可通过“轻点手牌”发动技能;手机尺寸下暂不开放。
+
+
+
+ ); +}; + +export default MobileBlocker; diff --git a/example/game_07_skill_gomoku/ui/src/app/hooks/useAutoResolvePending.ts b/example/game_07_skill_gomoku/ui/src/app/hooks/useAutoResolvePending.ts index 6809ca8..e601b8b 100644 --- a/example/game_07_skill_gomoku/ui/src/app/hooks/useAutoResolvePending.ts +++ b/example/game_07_skill_gomoku/ui/src/app/hooks/useAutoResolvePending.ts @@ -1,11 +1,11 @@ import { useEffect, useRef } from 'react'; import type { GameStatus } from '../../types'; -import { GamePhaseEnum, PlayerEnum } from '../../core/constants'; +import { GamePhaseEnum } from '../../core/constants'; /** - * Auto-resolve my (BLACK) pending card based on instant/non-instant semantics. + * Auto-resolve pending card based on instant/non-instant semantics (both sides). * - Instant: resolve after 7s - * - Non-instant: resolve when counter window expires + * - Non-instant: resolve when counter window expires (or immediately if already expired) */ export function useAutoResolvePending( gameState: GameStatus, @@ -20,7 +20,6 @@ export function useAutoResolvePending( } const pending = gameState.pendingAction; if (gameState.phase !== GamePhaseEnum.COUNTER_WINDOW || !pending) return; - if (pending.player !== PlayerEnum.BLACK) return; const isInstant = Boolean(pending.metadata?.uiInstant); const now = Date.now(); @@ -30,6 +29,12 @@ export function useAutoResolvePending( } else if (gameState.counterWindow) { ms = Math.max(0, (gameState.counterWindow.expiresAt ?? now) - now); } + // 若窗口已过期,立即推进结算(避免因标签页休眠而错过定时器) + if (ms === 0) { + // 使用微任务,让 React 有机会先清理上一次 effect + Promise.resolve().then(() => resolveCard(false, null)).catch(() => {}); + return; + } if (ms && ms > 0) { timerRef.current = window.setTimeout(() => resolveCard(false, null), ms) as unknown as number; } diff --git a/example/game_07_skill_gomoku/ui/src/app/hooks/useBackgroundMusic.ts b/example/game_07_skill_gomoku/ui/src/app/hooks/useBackgroundMusic.ts index 4496b72..6a8a10f 100644 --- a/example/game_07_skill_gomoku/ui/src/app/hooks/useBackgroundMusic.ts +++ b/example/game_07_skill_gomoku/ui/src/app/hooks/useBackgroundMusic.ts @@ -6,7 +6,9 @@ let sharedAudio: HTMLAudioElement | null = null; let sharedDidInit = false; let sharedIsPlaying = false; let sharedVolume = 0.2; -const listeners = new Set<(s: { isPlaying: boolean; volume: number }) => void>(); +// Persisted user preference: whether BGM is allowed to auto-play +let sharedEnabled = true; +const listeners = new Set<(s: { isPlaying: boolean; volume: number; enabled: boolean }) => void>(); type BgmPrefs = { enabled: boolean; @@ -40,6 +42,7 @@ const writePrefs = (prefs: BgmPrefs) => { export const useBackgroundMusic = () => { const [isPlaying, setIsPlaying] = useState(sharedIsPlaying); const [volume, setVolume] = useState(sharedVolume); + const [enabled, setEnabled] = useState(sharedEnabled); const audioRef = useRef(sharedAudio); // lazy init to avoid constructing audio element during SSR @@ -58,9 +61,10 @@ export const useBackgroundMusic = () => { // load persisted prefs once useEffect(() => { // subscribe to global state changes - const listener = (s: { isPlaying: boolean; volume: number }) => { + const listener = (s: { isPlaying: boolean; volume: number; enabled: boolean }) => { setIsPlaying(s.isPlaying); setVolume(s.volume); + setEnabled(s.enabled); }; listeners.add(listener); // first-time global init only @@ -69,7 +73,9 @@ export const useBackgroundMusic = () => { const prefs = readPrefs(); sharedVolume = prefs.volume; setVolume(sharedVolume); - if (prefs.enabled) { + sharedEnabled = prefs.enabled; + setEnabled(sharedEnabled); + if (sharedEnabled) { const audio = ensureAudio(); audio.volume = sharedVolume; audio.play().then(() => { @@ -96,7 +102,7 @@ export const useBackgroundMusic = () => { }, [volume]); const notify = () => { - listeners.forEach(fn => fn({ isPlaying: sharedIsPlaying, volume: sharedVolume })); + listeners.forEach(fn => fn({ isPlaying: sharedIsPlaying, volume: sharedVolume, enabled: sharedEnabled })); }; const play = useCallback(async () => { @@ -105,7 +111,9 @@ export const useBackgroundMusic = () => { await audio.play(); sharedIsPlaying = true; setIsPlaying(true); - writePrefs({ enabled: true, volume: sharedVolume }); + sharedEnabled = true; // user explicitly enabled + setEnabled(true); + writePrefs({ enabled: sharedEnabled, volume: sharedVolume }); notify(); } catch (err) { sharedIsPlaying = false; @@ -119,7 +127,9 @@ export const useBackgroundMusic = () => { sharedAudio.pause(); sharedIsPlaying = false; setIsPlaying(false); - writePrefs({ enabled: false, volume: sharedVolume }); + sharedEnabled = false; // user explicitly disabled + setEnabled(false); + writePrefs({ enabled: sharedEnabled, volume: sharedVolume }); notify(); }, []); @@ -131,12 +141,13 @@ export const useBackgroundMusic = () => { return { isPlaying, + enabled, volume, setVolume: (v: number) => { sharedVolume = Math.min(1, Math.max(0, v)); setVolume(sharedVolume); if (sharedAudio) sharedAudio.volume = sharedVolume; - writePrefs({ enabled: sharedIsPlaying, volume: sharedVolume }); + writePrefs({ enabled: sharedEnabled, volume: sharedVolume }); notify(); }, play, diff --git a/example/game_07_skill_gomoku/ui/src/app/hooks/useSoundEffects.ts b/example/game_07_skill_gomoku/ui/src/app/hooks/useSoundEffects.ts index c304c24..0584550 100644 --- a/example/game_07_skill_gomoku/ui/src/app/hooks/useSoundEffects.ts +++ b/example/game_07_skill_gomoku/ui/src/app/hooks/useSoundEffects.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { VisualEffectEvent } from '../../types'; +import { BOARD_SIZE } from '../../core/constants'; type SfxPrefs = { enabled: boolean; volume: number }; @@ -9,8 +10,18 @@ const STORAGE_KEY = 'game07.sfx'; let sharedEnabled = true; let sharedVolume = 0.5; let sharedCtx: AudioContext | null = null; +let unlockAttached = false; const listeners = new Set<(s: { enabled: boolean; volume: number }) => void>(); +// simple rotating index per effect key to add variation +const varIndex: Map = new Map(); +const nextVar = (key: string, modulo: number) => { + const cur = varIndex.get(key) ?? 0; + const nx = (cur + 1) % modulo; + varIndex.set(key, nx); + return cur; +}; + const notify = () => { listeners.forEach(fn => fn({ enabled: sharedEnabled, volume: sharedVolume })); }; const readPrefs = (): SfxPrefs => { @@ -35,28 +46,54 @@ const ensureCtx = () => { if (!sharedCtx) { sharedCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); } + // Try auto-resume and set up a one-time unlock on user gesture + try { if (sharedCtx.state === 'suspended') void sharedCtx.resume(); } catch {} + if (!unlockAttached) { + unlockAttached = true; + const unlock = async () => { + if (!sharedCtx) return; + try { await sharedCtx.resume(); } catch {} + if (sharedCtx.state === 'running') { + window.removeEventListener('pointerdown', unlock); + window.removeEventListener('keydown', unlock); + window.removeEventListener('touchstart', unlock, { capture: true } as any); + } + }; + window.addEventListener('pointerdown', unlock, { once: true }); + window.addEventListener('keydown', unlock, { once: true }); + window.addEventListener('touchstart', unlock, { once: true, capture: true } as any); + } return sharedCtx; }; // Low-level synth helpers use sharedVolume/sharedEnabled const synth = { - tone: (freq: number, durMs = 140) => { + // Basic tone, optionally scheduled with offset and custom waveform/volume; supports pan + tone: (freq: number, durMs = 140, startOffsetMs = 0, opts?: { type?: OscillatorType; vol?: number; pan?: number }) => { if (!sharedEnabled) return; const ctx = ensureCtx(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); - osc.type = 'triangle'; + osc.type = opts?.type ?? 'triangle'; osc.frequency.value = freq; - gain.gain.value = 0.0001; - osc.connect(gain).connect(ctx.destination); - const now = ctx.currentTime; + const vol = Math.max(0.0001, Math.min(1, opts?.vol ?? 0.4)) * sharedVolume; + let dest: AudioNode = ctx.destination; + if (typeof opts?.pan === 'number' && (ctx as any).createStereoPanner) { + const panner = (ctx as any).createStereoPanner(); + panner.pan.value = Math.max(-1, Math.min(1, opts.pan)); + panner.connect(ctx.destination); + dest = panner; + } + osc.connect(gain).connect(dest); + const now = ctx.currentTime + startOffsetMs / 1000; gain.gain.setValueAtTime(0.0001, now); - gain.gain.linearRampToValueAtTime(sharedVolume * 0.4, now + 0.02); + gain.gain.linearRampToValueAtTime(vol, now + 0.02); gain.gain.exponentialRampToValueAtTime(0.0001, now + durMs / 1000); osc.start(now); osc.stop(now + durMs / 1000 + 0.02); }, - noise: (centerHz: number, q = 4, durMs = 100, peak = 0.6) => { + // Bandpassed noise whoosh + noise: (centerHz: number, q = 4, durMs = 100, peak = 0.6, startOffsetMs = 0, opts?: { pan?: number }) => { if (!sharedEnabled) return; const ctx = ensureCtx(); const seconds = durMs / 1000; @@ -73,13 +110,28 @@ const synth = { biquad.frequency.value = centerHz; biquad.Q.value = q; const gain = ctx.createGain(); - src.connect(biquad).connect(gain).connect(ctx.destination); - const now = ctx.currentTime; + let dest: AudioNode = ctx.destination; + if (opts?.pan != null && (ctx as any).createStereoPanner) { + const panner = (ctx as any).createStereoPanner(); + panner.pan.value = Math.max(-1, Math.min(1, opts.pan)); + panner.connect(ctx.destination); + dest = panner; + } + src.connect(biquad).connect(gain).connect(dest); + const now = ctx.currentTime + startOffsetMs / 1000; gain.gain.setValueAtTime(0.0001, now); gain.gain.linearRampToValueAtTime(sharedVolume * peak, now + 0.01); gain.gain.exponentialRampToValueAtTime(0.0001, now + seconds); src.start(now); src.stop(now + seconds + 0.02); + }, + // Schedule a simple polyphonic chord + chord: (freqs: number[], durMs = 180, startOffsetMs = 0, opts?: { type?: OscillatorType; vol?: number; pan?: number }) => { + freqs.forEach(f => synth.tone(f, durMs, startOffsetMs, opts)); + }, + // Schedule a melody/sequence + seq: (steps: Array<{ f: number; d: number; at: number; type?: OscillatorType; vol?: number; pan?: number }>) => { + steps.forEach(step => synth.tone(step.f, step.d, step.at, { type: step.type, vol: step.vol, pan: step.pan })); } }; @@ -108,6 +160,7 @@ export const useSoundEffects = () => { const setEnabled = useCallback((v: boolean) => { sharedEnabled = v; + try { void ensureCtx().resume(); } catch {} writePrefs({ enabled: sharedEnabled, volume: sharedVolume }); notify(); }, []); @@ -122,42 +175,127 @@ export const useSoundEffects = () => { const playTone = useCallback((freq: number, durMs = 140) => synth.tone(freq, durMs), []); const playPlace = useCallback((durMs = 90) => synth.noise(900, 4, durMs, 0.55), []); - const playEffect = useCallback((effectId?: string, role?: 'attacker' | 'counter' | 'normal') => { + const playEffect = useCallback((effectId?: string, role?: 'attacker' | 'counter' | 'normal', opts?: { tags?: string; subtype?: string; pan?: number }) => { if (!sharedEnabled) return; + try { void ensureCtx().resume(); } catch {} + const tagSet = new Set((opts?.tags ?? '').split(/[|,;\s]+/).map(s => s.trim()).filter(Boolean)); + const subtype = (opts?.subtype ?? '').toLowerCase(); + const pan = typeof opts?.pan === 'number' ? Math.max(-1, Math.min(1, opts.pan)) : undefined; switch (effectId) { + case 'draw-card': { + // 轻柔抽卡:滤波噪声 + 轻提示,根据 role 稍微区分音高 + const base = role === 'counter' ? 1080 : 1240; + synth.noise(1600, 6, 80, 0.28, 0, { pan }); + synth.tone(base, 70, 30, { type: 'sine', vol: 0.28, pan }); + break; } + case 'play-card': { + // 出牌:卡片刷动 + 轻敲 + const t = tagSet.has('Counter') ? 1900 : tagSet.has('Recovery') ? 1750 : 1800; + synth.noise(2200, 8, 70, 0.25, 0, { pan }); + synth.tone(t, 40, 10, { type: 'triangle', vol: 0.22, pan }); + synth.tone(900, 60, 25, { type: 'sine', vol: 0.18, pan }); + break; } case 'remove-to-shichahai': // 力拔山兮 - synth.noise(800, 4, 120, 0.65); // 拍打 - synth.tone(300, 120); // 低沉 + synth.noise(800, 4, 140, 0.65, 0, { pan }); // 拍打 + synth.tone(260, 130, 0, { type: 'sawtooth', vol: 0.4, pan }); // 低沉 + synth.tone(180, 180, 40, { type: 'sine', vol: 0.28, pan }); // 回响 break; case 'freeze-opponent': - synth.tone(1400, 80); - synth.tone(1200, 100); + // 冰晶:细碎高频 + 轻和弦 + synth.tone(1700, 60, 0, { type: 'sine', vol: 0.22, pan }); + synth.tone(1400, 80, 30, { type: 'triangle', vol: 0.2, pan }); + synth.chord([1200, 1600], 120, 80, { type: 'sine', vol: 0.16, pan }); break; case 'clean-sweep': - synth.noise(1000, 2.5, 160, 0.5); + // 大扫除:呼的一下 + 下滑音 + synth.noise(1000, 2.5, 180, 0.5, 0, { pan }); + synth.tone(1000, 140, 40, { vol: 0.22, pan }); + synth.tone(700, 120, 120, { vol: 0.18, pan }); break; case 'skip-next-turn': - synth.noise(600, 2.0, 120, 0.4); + synth.noise(600, 2.0, 140, 0.4, 0, { pan }); break; case 'summon-character': - synth.tone(660, 90); - setTimeout(() => synth.tone(880, 110), 80); + // 召唤:小型上行分解和弦 + const up = ['aura', 'blessing', 'guardian'].includes(subtype); + const seq: Array<{ f: number; d: number; at: number; type: OscillatorType; vol: number; pan: number }> = up + ? [ + { f: 523, d: 110, at: 0, type: 'triangle', vol: 0.22, pan }, + { f: 659, d: 110, at: 90, type: 'triangle', vol: 0.22, pan }, + { f: 784, d: 140, at: 180, type: 'triangle', vol: 0.22, pan } + ] + : [ + { f: 784, d: 110, at: 0, type: 'triangle', vol: 0.22, pan }, + { f: 659, d: 110, at: 90, type: 'triangle', vol: 0.22, pan }, + { f: 523, d: 140, at: 180, type: 'triangle', vol: 0.22, pan } + ]; + synth.seq(seq); + synth.chord([523, 659, 784], 160, 340, { type: 'sine', vol: 0.2, pan }); break; case 'force-exit': - synth.tone(880, 80); - setTimeout(() => synth.tone(440, 120), 60); + if (nextVar('force-exit', 2) === 0) { + synth.tone(880, 80, 0, { pan }); + synth.tone(440, 120, 70, { pan }); + } else { + synth.tone(660, 90, 0, { pan }); + synth.tone(990, 120, 70, { pan }); + } break; + case 'instant-win': { + // 胜利:强力和弦 + 光晕 + const variants = [ + [523, 659, 783], + [659, 783, 987] + ]; + const v = variants[nextVar('instant-win', variants.length)]; + synth.chord(v, 220, 0, { type: 'sawtooth', vol: 0.28, pan }); + synth.noise(1400, 5, 220, 0.35, 40, { pan }); + break; } + case 'counter-restore-board': { + // 东山再起:上行恢复式旋律 + const paths = [ + [392, 523, 659, 784], + [349, 494, 659, 784] + ]; + const idx = nextVar('counter-restore-board', paths.length); + const steps = paths[idx]; + synth.seq([ + { f: steps[0], d: 120, at: 0, type: 'sine', vol: 0.2, pan }, + { f: steps[1], d: 120, at: 110, type: 'sine', vol: 0.2, pan }, + { f: steps[2], d: 130, at: 220, type: 'sine', vol: 0.22, pan }, + { f: steps[3], d: 160, at: 350, type: 'triangle', vol: 0.24, pan } + ]); + synth.chord([523, 659, 784], 240, 520, { type: 'triangle', vol: 0.22, pan }); + break; } + case 'counter-reverse-win': { + // 两极反转:紧张的上扬+和弦 + const bases = [[740, 880, 988], [659, 830, 987]]; + const pickIdx = nextVar('counter-reverse-win', bases.length); + const seqB = bases[pickIdx]; + synth.seq([ + { f: seqB[0], d: 80, at: 0, pan }, + { f: seqB[1], d: 90, at: 70, pan }, + { f: seqB[2], d: 100, at: 150, pan } + ]); + synth.chord(seqB, 220, 260, { type: 'sawtooth', vol: 0.26, pan }); + break; } default: { // 基础:根据角色选择音高 const base = role === 'counter' ? 1320 : role === 'attacker' ? 880 : 660; - synth.tone(base, 120); + synth.tone(base, 120, 0, { pan }); break; } } }, []); const playForEvent = useCallback((ev: VisualEffectEvent) => { - playEffect(ev.effectId, ev.role); + // derive pan from board column if provided (center=0, left=-1, right=+1) + let pan: number | undefined = undefined; + if (ev.cell && typeof ev.cell.col === 'number') { + const ratio = ev.cell.col / (BOARD_SIZE - 1); + pan = Math.max(-1, Math.min(1, ratio * 2 - 1)) * 0.6; + } + playEffect(ev.effectId, ev.role, { tags: ev.cardTags, subtype: ev.cardSubtype, pan }); }, [playEffect]); return { enabled, setEnabled, volume, setVolume, playForEvent, playEffect, playTone, playPlace }; diff --git a/example/game_07_skill_gomoku/ui/src/app/hooks/useVisualEffects.ts b/example/game_07_skill_gomoku/ui/src/app/hooks/useVisualEffects.ts index 8c2dc96..82098fe 100644 --- a/example/game_07_skill_gomoku/ui/src/app/hooks/useVisualEffects.ts +++ b/example/game_07_skill_gomoku/ui/src/app/hooks/useVisualEffects.ts @@ -23,8 +23,11 @@ export function useVisualEffects(gameState: GameStatus): VisualEffectEvent[] { next.forEach(ev => { trackerRef.current.add(ev.id); setActive(prev => [...prev, ev]); - const isCounterSkill = ev.effectId?.startsWith('Counter'); - const cleanupDelay = isCounterSkill ? 4200 : 3000; + // 根据角色与具体技能调整动画占用时长 + const isCounterSkill = ev.role === 'counter'; + let cleanupDelay = isCounterSkill ? 4200 : 3000; + if (ev.effectId === SkillEffect.CounterRestoreBoard) cleanupDelay = 4200; // 东山再起:反击动画更长 + if (ev.effectId === SkillEffect.InstantWin || ev.effectId === SkillEffect.CounterReverseWin) cleanupDelay = 4200; window.setTimeout(() => { setActive(prev => prev.filter(x => x.id !== ev.id)); }, cleanupDelay); diff --git a/example/game_07_skill_gomoku/ui/src/app/utils/replay.ts b/example/game_07_skill_gomoku/ui/src/app/utils/replay.ts new file mode 100644 index 0000000..f608aee --- /dev/null +++ b/example/game_07_skill_gomoku/ui/src/app/utils/replay.ts @@ -0,0 +1,66 @@ +import type { GameStatus } from '../../types'; + +export type ShareReplayPayload = { + v: 1; + winner: number | null; + turnCount: number; + timeline: GameStatus['timeline']; + shichahai: GameStatus['shichahai']; + players?: { me?: string; opp?: string; ai?: 'cloud' | 'local' }; + createdAt: number; +}; + +const encodeUtf8Base64 = (value: string): string => { + const bytes = new TextEncoder().encode(value); + let binary = ''; + bytes.forEach(byte => { + binary += String.fromCharCode(byte); + }); + return btoa(binary); +}; + +const decodeUtf8Base64 = (value: string): string => { + const binary = atob(value); + const bytes = Uint8Array.from(binary, char => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); +}; + +export const buildReplayPayload = (game: GameStatus, me?: string): ShareReplayPayload => ({ + v: 1, + winner: game.winner ?? null, + turnCount: game.turnCount, + timeline: game.timeline, + shichahai: game.shichahai, + players: me ? { me } : undefined, + createdAt: Date.now() +}); + +// 简单 base64 编码(不压缩)。如需缩短可换用 LZ-string。 +export const encodeReplay = (payload: ShareReplayPayload): string => { + const json = JSON.stringify(payload); + return encodeUtf8Base64(json); +}; + +export const decodeReplay = (raw: string): ShareReplayPayload | null => { + try { + const json = decodeUtf8Base64(raw); + const obj = JSON.parse(json); + if (!obj || typeof obj !== 'object') return null; + if (obj.v !== 1) return null; + return obj as ShareReplayPayload; + } catch { + return null; + } +}; + +export const findReplayParam = (): string | null => { + if (typeof window === 'undefined') return null; + const url = new URL(window.location.href); + const q = url.searchParams.get('replay'); + if (q) return q; + const hash = window.location.hash || ''; + if (hash.startsWith('#replay=')) return hash.slice('#replay='.length); + const idx = hash.indexOf('replay='); + if (idx >= 0) return hash.slice(idx + 'replay='.length); + return null; +}; diff --git a/example/game_07_skill_gomoku/ui/src/assets/skill5.png b/example/game_07_skill_gomoku/ui/src/assets/skill5.png new file mode 100644 index 0000000..79902d1 Binary files /dev/null and b/example/game_07_skill_gomoku/ui/src/assets/skill5.png differ diff --git a/example/game_07_skill_gomoku/ui/src/components/AiSettingsPanel.tsx b/example/game_07_skill_gomoku/ui/src/components/AiSettingsPanel.tsx index d21ab65..b2b75d4 100644 --- a/example/game_07_skill_gomoku/ui/src/components/AiSettingsPanel.tsx +++ b/example/game_07_skill_gomoku/ui/src/components/AiSettingsPanel.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react'; import type { AiSettings } from '../ai/openAiClient'; import { useBackgroundMusic } from '../app/hooks/useBackgroundMusic'; import { useSoundEffects } from '../app/hooks/useSoundEffects'; +import standPng from '../assets/stand.png'; +import standKaraPng from '../assets/stand_kara.png'; export interface AiSettingsPanelProps { open: boolean; settings: AiSettings; @@ -58,75 +60,72 @@ export const AiSettingsPanel: React.FC = ({ open, settings return (
-
-
-

AI 配置

- +
+ {/* Header with standing portraits */} +
+

AI 配置

+ + {/* portraits */} +
+ {/* black stand */} + white stand +
-
+ {/* 音频设置 */}
音频设置
+ {/* Prompt 更大 + 示例片段 */} +
+ 白方策略提示词(可选) +