Merge pull request 'hermes/hermes-1e775b03' (#13) from hermes/hermes-1e775b03 into master
CI / verify (push) Has been cancelled

Reviewed-on: http://82.157.175.59:3000/GenarrativeAI/Genarrative/pulls/13
This commit was merged in pull request #13.
This commit is contained in:
2026-05-12 15:11:49 +08:00
55 changed files with 5928 additions and 150 deletions
-2
View File
@@ -56,8 +56,6 @@ LLM_DEBUG_LOG="true"
ALIYUN_OSS_BUCKET="xushi-dev"
ALIYUN_OSS_REGION="oss-cn-beijing"
ALIYUN_OSS_ENDPOINT="oss-cn-beijing.aliyuncs.com"
ALIYUN_OSS_ACCESS_KEY_ID="LTAI5t7aiyw6uDFW4miJvU8f"
ALIYUN_OSS_ACCESS_KEY_SECRET="XblWGE6CO1WLnSBdMRVpL6lut4GSoS"
# Local Rust backend target for Vite dev proxy.
RUST_SERVER_TARGET="http://127.0.0.1:8082"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+14
View File
@@ -12,6 +12,20 @@
- 仓库 `.hermes/` 只保存可进入 Git 的团队共享内容;禁止提交个人 `~/.hermes` 配置、`.env`、API Key、Token、会话记录、认证文件和本地私密路径。
- 若 `.hermes/shared-memory/` 与当前代码或 `docs/` 最新文档冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。
## Agent skills
### Issue tracker
Issues are tracked in the self-hosted Gitea remote for this repo. Use Gitea Issues via the configured Gitea UI/API or `tea` CLI when available; do not use GitHub `gh` or GitLab `glab` unless the repo is migrated. See `docs/agents/issue-tracker.md`.
### Triage labels
Use the default canonical triage labels: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`.
### Domain docs
Single-context layout: read root `CONTEXT.md` when present and architecture decisions from `docs/adr/`. See `docs/agents/domain.md`.
## 项目约束
- 代码需要有完善的中文注释
- 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。
+36
View File
@@ -0,0 +1,36 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
## Layout
This repo uses a **single-context** layout for Matt Pocock engineering skills:
- `CONTEXT.md` at the repo root, when present, is the primary domain glossary/context file.
- `docs/adr/`, when present, contains architecture decision records.
- If either path does not exist, proceed silently; do not block the task just to create it.
## Before exploring, read these
1. Root `CONTEXT.md`, if present.
2. Relevant ADRs under `docs/adr/`, if present.
3. Existing project context that predates this setup:
- `.hermes/README.md`
- `.hermes/shared-memory/project-overview.md`
- `.hermes/shared-memory/team-conventions.md`
- `.hermes/shared-memory/development-workflow.md`
- `.hermes/shared-memory/decision-log.md`
- `.hermes/shared-memory/pitfalls.md`
- Relevant files under `docs/technical/`, `docs/prd/`, `docs/design/`, and `docs/experience/`
Follow `AGENTS.md` when it is more specific than this file. If older docs conflict with current code or newer technical docs, treat current code and newer docs as authoritative and update stale docs when the task requires it.
## Use the glossary's vocabulary
When your output names a domain concept in an issue title, refactor proposal, diagnosis, test name, or implementation plan, use the term as defined in `CONTEXT.md` when available. Do not drift to synonyms the glossary explicitly avoids.
If the concept you need is not in the glossary yet, either use the established vocabulary from `.hermes/shared-memory/` and `docs/`, or note the gap for a future documentation pass.
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding it.
+35
View File
@@ -0,0 +1,35 @@
# Issue tracker: Gitea
Issues and PRDs for this repo live as issues in the self-hosted Gitea remote:
- Remote: `http://82.157.175.59:3000/GenarrativeAI/Genarrative.git`
- Tracker type: Gitea Issues
## Conventions
- Prefer the Gitea `tea` CLI when it is installed and configured for this host.
- Do not use GitHub `gh` or GitLab `glab` for this repo unless the repository is explicitly migrated to those platforms.
- If `tea` is unavailable, use the Gitea Web UI or Gitea REST API for the same operations.
## Common operations with `tea`
Exact flags can vary by `tea` version. Run `tea issues --help` or `tea issue --help` before using a command in a new environment.
- Create an issue: `tea issues create --title "..." --body "..."`
- Read an issue: `tea issues view <number>`
- List issues: `tea issues list`
- Comment on an issue: use the installed `tea` issue comment command shown by `tea issues --help`; if unavailable, use the Gitea Web UI or REST API.
- Apply labels: use the installed `tea` issue update/edit command shown by `tea issues --help`; if unavailable, use the Gitea Web UI or REST API.
- Close an issue: use the installed `tea` issue close/update command shown by `tea issues --help`; if unavailable, use the Gitea Web UI or REST API.
## When a skill says "publish to the issue tracker"
Create a Gitea issue in `GenarrativeAI/Genarrative` with the requested title, body, labels, and links back to any relevant docs or branch.
## When a skill says "fetch the relevant ticket"
Read the Gitea issue body and comments/notes for the referenced issue number. Include labels and current open/closed state in the working context.
## Authentication
Use the locally configured Gitea credentials for the current developer. Do not commit tokens, cookies, `.env`, or local credential files.
+15
View File
@@ -0,0 +1,15 @@
# Triage Labels
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's Gitea issue tracker.
| Label in mattpocock/skills | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |
When a skill mentions a role, use the corresponding Gitea label string from this table.
If the Gitea repository later adopts Chinese labels or a different naming scheme, edit the right-hand column here rather than letting skills create duplicate labels.
+412
View File
@@ -0,0 +1,412 @@
# 汪汪声浪大作战 / bark-battle BDD 验收场景
## 背景
- 需求来源:用户提供的视频 `C:\Users\DSK\Videos\一款双方比狗叫的游戏 - 1.一款双方比狗叫的游戏(Av116504192360177,P1).mp4`,并已在 `.hermes/plans/2026-05-11_144229-bark-battle-2d-game-bdd-ddd-tdd-plan.md` 中完成抽帧分析和玩法方案整理。
- 玩法定位:浏览器 2D 声控狗叫对战小游戏,暂定中文名 `汪汪声浪大作战`,英文代号与 play type ID 建议为 `bark-battle`。
- 核心玩法:双方狗狗在 30 秒限时内通过麦克风输入“狗叫声”进行声浪拔河;系统依据声音强度、有效叫声次数和叫声节奏计算推动力,实时推动顶部红蓝能量条;倒计时结束后按能量条位置判定胜负或平局。
- 文档目的:为产品、测试、前端、后端在编码前统一可验证验收口径;本文只定义 PRD/BDD 级行为与测试映射,不实现工程代码。
## 角色与目标
### 主要角色
- 浏览器玩家:进入 `bark-battle` 玩法,用麦克风发声参与对战。
- 移动端玩家:在手机浏览器中进入玩法,需要看到核心游戏信息并完成授权、对战、结算。
- 无麦克风或无 API 支持玩家:设备或浏览器不支持声控输入时,需要得到明确降级反馈并可返回。
- 测试人员:依据本文场景验证权限、校准、叫声识别、能量条、胜负结算和布局兼容。
- 前端实现人员:依据本文拆分 Web Audio 输入、领域规则、Phaser/DOM HUD 表现和自动化测试。
### 用户目标
- 玩家可以在开局前完成麦克风授权和环境噪音校准。
- 玩家发出有效狗叫时,能看到叫声计数、狗狗动画、拟声词/冲击波以及能量条变化。
- 低于阈值的背景噪音不会被误计为有效叫声。
- 单局在 30 秒后给出明确胜负、平局和关键数据。
- 移动端和不支持麦克风的环境不会进入不可操作状态。
### 非目标
- MVP 不要求识别“是否真实狗叫”,不引入机器学习声纹/物种分类;有效输入以音量阈值、峰值间隔、持续时间和校准结果为准。
- MVP 不要求实时联机对战;可先按“玩家 vs AI 对手”完成单机浏览器 runtime。
- MVP 不要求成绩持久化、作品发布、作品架、广场和排行榜;若后续接入 Genarrative 作品闭环,需要另补玩法类型集成 PRD/技术文档。
- MVP 不要求在 UI 中长期展示大段规则说明;游戏界面应保持倒计时、能量条、狗狗、麦克风状态和结算信息为主。
- MVP 不处理竞技级反作弊;播放录音、拍桌、喊叫等非狗叫输入只作为后续公平性风险记录。
## 业务规则口径
- 单局时长:默认 30 秒,从正式进入 `playing` 阶段开始计时。
- 能量条:使用 `-100` 到 `100` 的连续值表示,负数偏对手侧,正数偏玩家侧,`0` 为中线。
- 平局阈值:倒计时结束时,若能量条绝对值小于或等于 `drawThreshold`,判定平局;具体数值由实现配置,但测试需可注入固定阈值。
- 有效叫声:一次有效叫声至少满足:音量超过校准后的有效阈值、与上一次有效峰值间隔不小于 `minBarkGapMs`、持续时长在 `minBarkDurationMs` 到 `maxBarkDurationMs` 之间。
- 背景噪音:校准阶段采集到的环境声用于计算动态阈值;低于阈值的输入不得增加叫声次数,也不得让能量条出现可见推进。
- 推动力:玩家推动力由音量分数、有效叫声频率和连击加成组成;能量条按玩家推动力与对手推动力差值移动,并被限制在 `-100` 到 `100`。
- UI 反馈:有效叫声应触发可观察反馈,包括玩家侧狗狗张嘴/吠叫动画、拟声词或冲击波;反馈不应遮挡倒计时和顶部能量条。
## 中文 Gherkin 场景
### 功能: 麦克风授权与开局准备
```gherkin
功能: 狗叫对战麦克风准备
为了让玩家能用声音参与对战
作为浏览器玩家
我希望游戏在正式开局前请求麦克风权限并完成环境校准
背景:
假如玩家进入 bark-battle 玩法页面
而且浏览器支持 navigator.mediaDevices.getUserMedia
场景: 玩家允许麦克风权限后进入环境噪音校准
当玩家同意浏览器麦克风授权
那么系统应进入环境噪音校准阶段
而且游戏不应在校准完成前进入 playing 阶段
而且界面应显示麦克风已授权的可观察状态
场景: 校准完成后进入开局倒计时
假如玩家已允许麦克风权限
而且系统已采集足够的环境噪音样本
当校准计算出有效叫声阈值
那么系统应进入开局倒计时阶段
而且倒计时结束后应进入 30 秒对战阶段
而且初始能量条应位于中线
场景: 玩家拒绝麦克风权限后不能开始声控对战
当玩家拒绝浏览器麦克风授权
那么系统应停留在无法声控游玩的状态
而且应提供重新授权或返回入口
而且不应进入校准、倒计时或 playing 阶段
```
### 功能: 环境噪音校准
```gherkin
功能: 环境噪音校准
为了减少背景噪音误触发
作为浏览器玩家
我希望游戏在开局前根据当前环境设置有效叫声阈值
场景: 安静环境生成低但非零的有效阈值
假如校准阶段采集到的环境噪音 RMS 稳定低于默认噪音基线
当系统完成校准
那么有效叫声阈值应高于环境噪音平均值
而且阈值不应低于系统配置的最小阈值
场景: 嘈杂环境生成更高的有效阈值
假如校准阶段采集到的环境噪音 RMS 高于默认噪音基线
当系统完成校准
那么有效叫声阈值应随环境噪音上调
而且低于该阈值的后续输入不应计为有效叫声
场景: 校准期间无法获得有效音频样本
假如麦克风授权成功但音频样本持续为空或不可读
当校准超过系统配置的最长等待时间
那么系统应展示麦克风输入不可用状态
而且应提供重试校准入口
而且不应直接开始对战
```
### 功能: 有效叫声计数
```gherkin
功能: 有效叫声计数
为了把玩家的狗叫行为转换为可计分输入
作为玩家
我希望每次符合规则的短促叫声只被计数一次
背景:
假如游戏处于 30 秒 playing 阶段
而且系统已完成环境噪音校准
场景: 单次超过阈值且间隔足够的叫声计数加一
假如玩家当前叫声次数为 0
而且上一次有效叫声时间早于 minBarkGapMs
当麦克风输入出现一次超过有效阈值且持续时长合规的峰值
那么玩家叫声次数应变为 1
而且玩家侧应出现一次吠叫动画反馈
而且画面应出现一次拟声词或冲击波反馈
场景: 持续噪音不会被无限计数
假如玩家当前叫声次数为 1
当麦克风输入持续超过阈值但没有新的峰值间隔
那么玩家叫声次数不应在每个 tick 中持续增加
而且系统最多只应记录当前连续声音段内的一次有效叫声
场景: 间隔过短的连续峰值不重复计数
假如玩家刚刚产生一次有效叫声
当麦克风输入在 minBarkGapMs 内再次出现峰值
那么玩家叫声次数不应增加
而且连击或推动力不应因该峰值重复加成
```
### 功能: 声音大小和连续叫声推动能量条
```gherkin
功能: 声浪推动能量条
为了复刻双方比狗叫的核心体验
作为玩家
我希望更响、更连续的有效叫声能把顶部能量条推向自己一侧
背景:
假如游戏处于 30 秒 playing 阶段
而且能量条当前位于中线
场景: 玩家推动力高于对手时能量条向玩家侧移动
假如玩家在短时间窗口内产生多次有效叫声
而且玩家推动力高于对手推动力
当系统推进一个 simulation tick
那么能量条数值应向玩家侧增加
而且顶部红蓝能量条的玩家侧占比应变大
场景: 连续大声叫声触发更强反馈
假如玩家连续产生多次高于强叫声阈值的有效叫声
当系统计算玩家连击加成
那么玩家侧推动力应高于单次普通叫声推动力
而且玩家侧声浪或冲击波反馈应比普通叫声更明显
但是反馈不应遮挡倒计时和能量条
场景: 能量条到达边界后不会越界
假如能量条已经接近玩家侧最大值
而且玩家推动力仍高于对手推动力
当系统推进多个 simulation tick
那么能量条数值不应超过 100
而且界面不应显示超出容器范围的能量条
场景: 对手推动力高于玩家时能量条向对手侧移动
假如对手推动力高于玩家推动力
当系统推进一个 simulation tick
那么能量条数值应向对手侧减少
而且顶部红蓝能量条的对手侧占比应变大
```
### 功能: 低噪音和无效输入过滤
```gherkin
功能: 背景噪音过滤
为了避免环境声替玩家自动得分
作为玩家
我希望低于阈值或不合规的声音不会被当作有效狗叫
背景:
假如游戏处于 30 秒 playing 阶段
而且系统已完成环境噪音校准
场景: 低于阈值的背景噪音不计数
当麦克风只接收到低于有效叫声阈值的背景噪音
那么玩家叫声次数不应增加
而且玩家侧不应播放吠叫动画
而且能量条不应因为该背景噪音出现可见推进
场景: 过短脉冲不计为有效叫声
假如麦克风输入峰值超过有效阈值
但是持续时长短于 minBarkDurationMs
当系统完成该声音段判定
那么玩家叫声次数不应增加
而且不应触发连击加成
场景: 过长持续声被削弱为单段输入
假如麦克风输入持续超过有效阈值
但是持续时长长于 maxBarkDurationMs
当系统完成该声音段判定
那么系统不应按多个叫声重复计数
而且该声音段的推动力应按持续噪音削弱规则处理
```
### 功能: 倒计时与胜负结算
```gherkin
功能: 狗叫对战胜负结算
为了让单局对抗有明确目标
作为玩家
我希望 30 秒倒计时结束后根据能量条位置得到胜负或平局
背景:
假如游戏已经进入 30 秒 playing 阶段
场景: 倒计时每秒递减并在归零时停止对战输入
当系统时间从 30 秒推进到 0 秒
那么界面应显示倒计时归零
而且系统应进入 finished 结算阶段
而且归零后的麦克风输入不应再改变本局能量条和叫声次数
场景: 玩家侧占优时判定玩家胜利
假如倒计时归零时能量条数值大于 drawThreshold
当系统进入结算阶段
那么系统应判定玩家胜利
而且结算面板应展示玩家叫声次数、最大音量和声浪评分
而且应提供再来一局入口
场景: 对手侧占优时判定玩家失败
假如倒计时归零时能量条数值小于 -drawThreshold
当系统进入结算阶段
那么系统应判定对手胜利
而且结算面板应展示玩家叫声次数、最大音量和声浪评分
而且应提供再来一局入口
场景: 能量条接近平衡时判定平局
假如倒计时归零时能量条数值位于 -drawThreshold 到 drawThreshold 之间
当系统进入结算阶段
那么系统应判定为平局
而且结算面板应展示双方接近平衡的结果
而且应提供再来一局入口
```
### 功能: 再来一局与状态重置
```gherkin
功能: 对战重开
为了让玩家快速再次挑战
作为玩家
我希望结算后可以开始新的一局且旧状态不会污染新局
场景: 结算后点击再来一局重置本局状态
假如系统处于 finished 结算阶段
而且结算面板展示上一局结果
当玩家选择再来一局
那么系统应重置剩余时间为 30 秒
而且能量条应回到中线
而且玩家叫声次数、最大音量、连击和胜负结果应清零
而且系统应重新进入校准或开局倒计时流程
场景: 结算后返回玩法入口
假如系统处于 finished 结算阶段
当玩家选择返回入口
那么系统应离开当前对战运行态
而且不应继续采集麦克风输入
```
### 功能: 移动端布局
```gherkin
功能: 移动端 bark-battle 布局
为了让手机浏览器玩家可以完成声控对战
作为移动端玩家
我希望核心信息在窄屏中保持可见且可操作
场景: 移动端进入对战页面时核心元素可见
假如玩家使用宽度不超过 430px 的移动端视口
当玩家进入 bark-battle 页面
那么顶部能量条应完整显示在首屏可见区域内
而且倒计时应可见
而且双方狗狗主体不应被权限、设置或说明面板长期遮挡
而且非关键设置应收起到菜单或次级入口中
场景: 移动端授权和开始必须由用户手势触发
假如玩家使用移动端浏览器
当玩家点击开始或授权入口
那么系统才应请求麦克风权限并激活音频上下文
而且不应在页面自动加载时直接启动 AudioContext
场景: 移动端结算面板不遮挡主要操作
假如玩家在移动端完成一局对战
当系统展示结算面板
那么胜负结果、再来一局和返回入口应在不横向滚动的情况下可见
而且结算面板不应要求玩家阅读大段规则说明才能继续
```
### 功能: 无 getUserMedia 或不可用环境降级
```gherkin
功能: 无麦克风 API 降级
为了避免不支持设备进入卡死状态
作为无麦克风或无 API 支持玩家
我希望系统明确告知无法声控游玩并提供退出路径
场景: 当前浏览器不支持 getUserMedia
假如玩家设备不支持 navigator.mediaDevices.getUserMedia
当玩家进入 bark-battle 页面
那么系统应显示设备或浏览器不支持麦克风输入的状态
而且应提供返回入口
而且不应展示可开始声控对战的按钮
场景: getUserMedia 调用失败但浏览器 API 存在
假如浏览器支持 getUserMedia
但是请求麦克风时返回 NotFoundError 或 NotReadableError
当系统接收到失败结果
那么系统应展示麦克风不可用状态
而且应提供重试授权或返回入口
而且不应进入 playing 阶段
场景: 非安全上下文导致麦克风不可用
假如页面运行在浏览器不允许麦克风的非安全上下文
当玩家进入 bark-battle 页面
那么系统应展示当前环境无法使用麦克风的状态
而且应提示使用受支持的安全环境或返回
而且不应开始对战倒计时
```
### 功能: 刷新与退出时释放麦克风资源
```gherkin
功能: 麦克风资源释放
为了保护用户隐私并避免浏览器资源泄漏
作为浏览器玩家
我希望离开对战时麦克风采集被停止
场景: 对战中离开页面停止采集
假如玩家已经授权麦克风并处于 playing 阶段
当玩家离开 bark-battle 页面或运行态卸载
那么系统应停止当前 MediaStream 的所有音轨
而且不应继续推进本局 simulation tick
场景: 刷新页面后不沿用旧局临时状态
假如玩家在 playing 阶段刷新页面
当页面重新加载 bark-battle
那么系统应重新进入权限检查或授权准备状态
而且不应沿用刷新前的剩余时间、能量条和叫声次数作为新局结果
```
## 测试映射
| 场景 | 测试层级 | 建议目标文件 | 自动化状态 |
| --- | --- | --- | --- |
| 玩家允许麦克风权限后进入环境噪音校准 | application/component | `src/games/bark-battle/application/BarkBattleController.test.ts`, `src/games/bark-battle/ui/BarkBattlePermissionPanel.test.tsx` | planned |
| 校准完成后进入开局倒计时 | application/unit | `src/games/bark-battle/application/BarkBattleController.test.ts`, `src/games/bark-battle/domain/BarkBattleSession.test.ts` | planned |
| 玩家拒绝麦克风权限后不能开始声控对战 | application/component | `src/games/bark-battle/application/BarkBattleController.test.ts`, `src/games/bark-battle/ui/BarkBattlePermissionPanel.test.tsx` | planned |
| 安静环境生成低但非零的有效阈值 | unit | `src/games/bark-battle/domain/BarkNoiseCalibration.test.ts` | planned |
| 嘈杂环境生成更高的有效阈值 | unit | `src/games/bark-battle/domain/BarkNoiseCalibration.test.ts` | planned |
| 校准期间无法获得有效音频样本 | application/component | `src/games/bark-battle/application/BarkBattleController.test.ts`, `src/games/bark-battle/ui/BarkBattlePermissionPanel.test.tsx` | planned |
| 单次超过阈值且间隔足够的叫声计数加一 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
| 持续噪音不会被无限计数 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
| 间隔过短的连续峰值不重复计数 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
| 玩家推动力高于对手时能量条向玩家侧移动 | unit | `src/games/bark-battle/domain/EnergyTugOfWar.test.ts` | planned |
| 连续大声叫声触发更强反馈 | unit/integration/component | `src/games/bark-battle/domain/BarkBattleScoring.test.ts`, `src/games/bark-battle/ui/BarkBattleHud.test.tsx` | planned |
| 能量条到达边界后不会越界 | unit | `src/games/bark-battle/domain/EnergyTugOfWar.test.ts` | planned |
| 对手推动力高于玩家时能量条向对手侧移动 | unit | `src/games/bark-battle/domain/EnergyTugOfWar.test.ts` | planned |
| 低于阈值的背景噪音不计数 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
| 过短脉冲不计为有效叫声 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
| 过长持续声被削弱为单段输入 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
| 倒计时每秒递减并在归零时停止对战输入 | unit/application | `src/games/bark-battle/domain/BarkBattleSession.test.ts`, `src/games/bark-battle/application/BarkBattleController.test.ts` | planned |
| 玩家侧占优时判定玩家胜利 | unit/component | `src/games/bark-battle/domain/BarkBattleSession.test.ts`, `src/games/bark-battle/ui/BarkBattleResultPanel.test.tsx` | planned |
| 对手侧占优时判定玩家失败 | unit/component | `src/games/bark-battle/domain/BarkBattleSession.test.ts`, `src/games/bark-battle/ui/BarkBattleResultPanel.test.tsx` | planned |
| 能量条接近平衡时判定平局 | unit/component | `src/games/bark-battle/domain/BarkBattleSession.test.ts`, `src/games/bark-battle/ui/BarkBattleResultPanel.test.tsx` | planned |
| 结算后点击再来一局重置本局状态 | application/component | `src/games/bark-battle/application/BarkBattleController.test.ts`, `src/games/bark-battle/ui/BarkBattleResultPanel.test.tsx` | planned |
| 结算后返回玩法入口 | integration/smoke | `src/games/bark-battle/application/BarkBattleController.test.ts`, Playwright 或人工 smoke 清单 | planned |
| 移动端进入对战页面时核心元素可见 | component/visual/smoke | `src/games/bark-battle/ui/BarkBattleHud.test.tsx`, Playwright 移动端视口 smoke | planned |
| 移动端授权和开始必须由用户手势触发 | infrastructure/application/e2e-smoke | `src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts`, `src/games/bark-battle/application/__tests__/BarkBattleController.test.ts`, Playwright 移动端 smoke | planned |
| 移动端结算面板不遮挡主要操作 | component/visual/smoke | `src/games/bark-battle/ui/__tests__/BarkBattleResultPanel.test.tsx`, Playwright 移动端视口 smoke | planned |
| 当前浏览器不支持 getUserMedia | infrastructure/component | `src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts`, `src/games/bark-battle/ui/__tests__/BarkBattlePermissionPanel.test.tsx` | planned |
| getUserMedia 调用失败但浏览器 API 存在 | infrastructure/application/component | `src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts`, `src/games/bark-battle/application/__tests__/BarkBattleController.test.ts`, `src/games/bark-battle/ui/__tests__/BarkBattlePermissionPanel.test.tsx` | planned |
| 非安全上下文导致麦克风不可用 | infrastructure/application/component | `src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts`, `src/games/bark-battle/application/__tests__/BarkBattleController.test.ts`, `src/games/bark-battle/ui/__tests__/BarkBattlePermissionPanel.test.tsx` | planned |
| 对战中离开页面停止采集 | infrastructure/application/integration | `src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts`, `src/games/bark-battle/application/__tests__/BarkBattleController.test.ts` | planned |
| 刷新页面后不沿用旧局临时状态 | integration/smoke | Playwright 或人工 smoke 清单 | planned |
## 验收清单
- [ ] 权限允许、拒绝、非安全上下文、API 不支持、麦克风未找到/不可读、AudioContext 被拦截、校准超时或样本不可读均有明确状态,且不会误进入 playing。
- [ ] 校准阶段会影响有效叫声阈值,低噪音不会增加叫声计数。
- [ ] 有效叫声计数具备阈值、峰值间隔、持续时长约束。
- [ ] 能量条根据双方推动力差值双向移动,并限制在 `-100` 到 `100`。
- [ ] 30 秒归零后停止本局输入影响,并按玩家胜利、对手胜利、平局三类结果结算。
- [ ] 移动端核心元素可见,非关键设置收起,不在主画面堆叠长规则说明。
- [ ] 离开页面或返回入口时停止麦克风采集。
- [ ] 每个编码依据场景已在测试映射中标注测试层级和建议文件。
## 开放问题
1. MVP 是否确认只做“玩家 vs AI”,还是第一版需要双人同屏或联机对战?
2. `drawThreshold`、`minBarkGapMs`、`minBarkDurationMs`、`maxBarkDurationMs` 的首版默认值由产品/调参阶段确认,还是先采用开发可配置默认值?
3. 是否允许无麦克风设备提供键盘/点击备用输入?若允许,需要另补非声控模式场景;若不允许,当前降级只提供返回入口。
4. 是否需要在结算中记录或上报成绩、最高音量、叫声次数和声浪评分?若需要,需补埋点/后端持久化场景。
5. bark-battle 是否作为 Genarrative 正式 play type 接入创作入口、作品发布和广场,还是先作为独立 runtime 原型验证?
6. 狗狗、背景、拟声词和冲击波素材来源是临时占位、AI 生成,还是复用项目现有素材管线?
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -4,6 +4,8 @@
## 文档列表
- [BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”后端 DDD 技术方案,明确 `server-rs + Axum + SpacetimeDB` 分层边界、shared contracts、作品配置、runtime run、派生成绩、排行榜、`work_play_start` 埋点、migration/绑定生成策略,以及不保存原始麦克风音频的隐私与反作弊约束。
- [BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”2D 浏览器 runtime 技术方案,明确 Phaser + TypeScript + Vite 选型、纯 TS simulation 与 Phaser renderer/DOM HUD 边界、Web Audio 输入适配、移动端权限降级和后续测试验证命令。
- [PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md](./PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md):记录直接访问公开作品详情深链时作品不存在或已下架的回首页修复,避免关闭提示后停在 `work-detail` 空状态白屏。
- [CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md](./CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md):冻结儿童动作识别互动玩法 Demo 固定热身关的开发落地规格,覆盖横屏展示、摄像头背景虚化、角色剪影、绿色圆环 2 秒保持、动作教学、当前会话内空间边界记录和后续关卡安全暂停规则。
- [RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md](./RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md):记录运行态输入设备抽象层,明确鼠标、触控、mocap 等设备统一归一为通用拖拽语义,玩法组件只负责解释目标和落点。
@@ -0,0 +1,17 @@
[
{
"id": "bark-battle-player-back",
"title": "玩家背对屏幕狗狗",
"prompt": "竖屏手机游戏素材,背对屏幕的可爱小狗,站在下半屏中央,耳朵竖起,身体朝向远处对手,夸张卡通 2D 手游风,轮廓清晰,暖橙色毛发,适合做 sprite,透明背景或纯色背景,无文字、水印、UI、边框"
},
{
"id": "bark-battle-opponent-front",
"title": "对手面向屏幕狗狗",
"prompt": "竖屏手机游戏素材,面向屏幕的可爱小狗,站在上半屏中央,张嘴准备汪汪叫,夸张卡通 2D 手游风,轮廓清晰,紫蓝色竞技光效,适合做 sprite,透明背景或纯色背景,无文字、水印、UI、边框"
},
{
"id": "bark-battle-bark-particles",
"title": "汪字粒子声浪",
"prompt": "竖屏手机游戏特效素材,画面中心必须是完整清晰的中文汉字“汪”,包含左侧三点水偏旁“氵”和右侧“王”,字体由金黄色发光粒子组成,字形周围向外扩散圆形声浪冲击波与粉色火花,深色纯背景便于叠加,适合做游戏粒子特效贴图,无其他文字、水印、按钮、UI,不要只生成“王”字"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

+145
View File
@@ -0,0 +1,145 @@
import { Buffer } from 'node:buffer';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const repoRoot = process.cwd();
const promptsPath = path.join(repoRoot, 'public', 'bark-battle-assets', 'bark-battle-image-prompts.json');
const outDir = path.join(repoRoot, 'public', 'bark-battle-assets', 'generated');
const args = new Set(process.argv.slice(2));
function readDotenv(fileName) {
const filePath = path.join(repoRoot, fileName);
if (!existsSync(filePath)) return {};
const values = {};
for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(trimmed);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
values[match[1]] = value;
}
return values;
}
function resolveEnv() {
const loaded = {
...readDotenv('.env.example'),
...readDotenv('.env.local'),
...readDotenv('.env.secrets.local'),
...process.env,
};
return {
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '').trim().replace(/\/+$/u, ''),
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
timeoutMs: Number.parseInt(String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || 180000), 10),
};
}
function generationUrl(baseUrl) {
return baseUrl.endsWith('/v1') ? `${baseUrl}/images/generations` : `${baseUrl}/v1/images/generations`;
}
function collectStringsByKey(value, targetKey, output) {
if (Array.isArray(value)) {
value.forEach((entry) => collectStringsByKey(entry, targetKey, output));
return;
}
if (!value || typeof value !== 'object') return;
for (const [key, nested] of Object.entries(value)) {
if (key === targetKey) {
if (typeof nested === 'string' && nested.trim()) output.push(nested.trim());
if (Array.isArray(nested)) nested.forEach((entry) => typeof entry === 'string' && entry.trim() && output.push(entry.trim()));
}
collectStringsByKey(nested, targetKey, output);
}
}
function inferExtensionFromBytes(bytes) {
if (bytes.subarray(0, 8).equals(Buffer.from('\x89PNG\r\n\x1A\n', 'binary'))) return 'png';
if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) return 'jpg';
if (bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP') return 'webp';
return 'png';
}
async function fetchJson(url, options, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try {
const response = await fetch(url, { ...options, signal: abortController.signal });
const text = await response.text();
if (!response.ok) throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 300)}`);
return JSON.parse(text);
} finally {
clearTimeout(timer);
}
}
async function downloadUrl(url, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) throw new Error(`download ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
const type = response.headers.get('content-type') || '';
const extension = type.includes('webp') ? 'webp' : type.includes('jpeg') ? 'jpg' : 'png';
return { bytes, extension };
} finally {
clearTimeout(timer);
}
}
const rawTemplates = JSON.parse(readFileSync(promptsPath, 'utf8'));
const onlyIds = process.argv
.slice(2)
.flatMap((arg, index, values) => (arg === '--only' ? String(values[index + 1] || '').split(',') : []))
.map((value) => value.trim())
.filter(Boolean);
const templates = rawTemplates.filter((template) => !onlyIds.length || onlyIds.includes(template.id));
const dryRun = args.has('--dry-run') || !args.has('--live');
const requests = templates.map((template) => ({ id: template.id, title: template.title, body: { model: 'gpt-image-2-all', prompt: template.prompt, n: 1, size: '1024x1024' } }));
if (dryRun) {
console.log(JSON.stringify({ mode: 'dry-run', outDir, count: requests.length, requests }, null, 2));
process.exit(0);
}
const env = resolveEnv();
if (!env.baseUrl || !env.apiKey) {
console.error(JSON.stringify({ ok: false, error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY', hasBaseUrl: Boolean(env.baseUrl), hasApiKey: Boolean(env.apiKey) }));
process.exit(1);
}
mkdirSync(outDir, { recursive: true });
const files = [];
for (const request of requests) {
console.log(`Generating ${request.id}...`);
const payload = await fetchJson(generationUrl(env.baseUrl), {
method: 'POST',
headers: { Authorization: `Bearer ${env.apiKey}`, Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(request.body),
}, env.timeoutMs);
const urls = [];
const b64 = [];
collectStringsByKey(payload, 'url', urls);
collectStringsByKey(payload, 'image', urls);
collectStringsByKey(payload, 'image_url', urls);
collectStringsByKey(payload, 'b64_json', b64);
let image;
const url = [...new Set(urls)].find((item) => /^https?:\/\//u.test(item));
if (url) {
image = await downloadUrl(url, env.timeoutMs);
} else if (b64[0]) {
const bytes = Buffer.from(b64[0], 'base64');
image = { bytes, extension: inferExtensionFromBytes(bytes) };
} else {
throw new Error(`VectorEngine returned no image for ${request.id}`);
}
const outputPath = path.join(outDir, `${request.id}.${image.extension}`);
writeFileSync(outputPath, image.bytes);
files.push(outputPath);
}
console.log(JSON.stringify({ ok: true, count: files.length, files }, null, 2));
+7 -1
View File
@@ -1,4 +1,7 @@
import crypto from 'node:crypto';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { pathToFileURL } from 'node:url';
if (crypto.webcrypto) {
if (typeof crypto.getRandomValues !== 'function') {
@@ -13,4 +16,7 @@ if (crypto.webcrypto) {
}
}
await import('../node_modules/vite/bin/vite.js');
const require = createRequire(import.meta.url);
const vitePackageJsonPath = require.resolve('vite/package.json');
const viteBinPath = join(dirname(vitePackageJsonPath), 'bin', 'vite.js');
await import(pathToFileURL(viteBinPath).href);
+5
View File
@@ -0,0 +1,5 @@
import { BarkBattleRuntimeShell } from './games/bark-battle/ui/BarkBattleRuntimeShell';
export default function BarkBattlePlaygroundApp() {
return <BarkBattleRuntimeShell />;
}
+31 -18
View File
@@ -6,51 +6,64 @@ import type {
} from '../packages/shared/src/contracts/match3dRuntime';
import { Match3DRuntimeShell } from './components/match3d-runtime';
import {
confirmLocalMatch3DClick,
resolveLocalMatch3DTimer,
createLocalMatch3DRuntimeAdapter,
type Match3DRuntimeAdapter,
startLocalMatch3DRun,
} from './services/match3d-runtime';
function buildInitialRun() {
type LocalMatch3DRuntimeSession = {
adapter: Match3DRuntimeAdapter;
initialRun: Match3DRunSnapshot;
};
function resolveClearCountParam() {
const params = new URLSearchParams(window.location.search);
const clearCountParam = params.get('clearCount') ?? params.get('count');
const clearCount =
clearCountParam === null ? 12 : Number.parseInt(clearCountParam, 10);
return startLocalMatch3DRun(
Number.isFinite(clearCount) && clearCount > 0 ? clearCount : 12,
);
return Number.isFinite(clearCount) && clearCount > 0 ? clearCount : 12;
}
function buildInitialRuntimeSession(): LocalMatch3DRuntimeSession {
const initialRun = startLocalMatch3DRun(resolveClearCountParam());
return {
adapter: createLocalMatch3DRuntimeAdapter({ initialRun }),
initialRun,
};
}
export default function Match3DPlaygroundApp() {
const [run, setRun] = useState<Match3DRunSnapshot>(buildInitialRun);
const authorityRunRef = useRef(run);
const runtimeSessionRef = useRef(buildInitialRuntimeSession());
const [run, setRun] = useState<Match3DRunSnapshot>(
runtimeSessionRef.current.initialRun,
);
const syncRun = useCallback((nextRun: Match3DRunSnapshot) => {
setRun(nextRun);
}, []);
const handleClickItem = useCallback(async (payload: Match3DClickItemRequest) => {
const result = await confirmLocalMatch3DClick(authorityRunRef.current, payload);
authorityRunRef.current = result.run;
const runId = payload.runId ?? runtimeSessionRef.current.initialRun.runId;
const result = await runtimeSessionRef.current.adapter.clickItem(runId, payload);
setRun(result.run);
return result;
}, []);
const handleRestart = useCallback(() => {
const nextRun = buildInitialRun();
authorityRunRef.current = nextRun;
setRun(nextRun);
}, []);
void runtimeSessionRef.current.adapter.restartRun(run.runId).then(({ run }) => {
setRun(run);
});
}, [run.runId]);
const handleExit = useCallback(() => {
window.location.assign('/');
}, []);
const handleTimeExpired = useCallback(() => {
const nextRun = resolveLocalMatch3DTimer(authorityRunRef.current);
authorityRunRef.current = nextRun;
setRun(nextRun);
}, []);
void runtimeSessionRef.current.adapter.finishTimeUp(run.runId).then(({ run }) => {
setRun(run);
});
}, [run.runId]);
return (
<Match3DRuntimeShell
@@ -197,6 +197,20 @@ export function CustomWorldCreationHub({
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
onOpenRpgDraft: onOpenDraft,
onEnterRpgPublished: onEnterPublished,
onDeleteRpg: onDeletePublished ?? undefined,
onOpenBigFishDetail,
onDeleteBigFish: onDeleteBigFish ?? undefined,
onOpenMatch3DDetail,
onDeleteMatch3D: onDeleteMatch3D ?? undefined,
onOpenSquareHoleDetail,
onDeleteSquareHole: onDeleteSquareHole ?? undefined,
onOpenPuzzleDetail,
onDeletePuzzle: onDeletePuzzle ?? undefined,
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
getItemState: getWorkState,
}),
[
@@ -210,6 +224,14 @@ export function CustomWorldCreationHub({
onDeletePublished,
onDeletePuzzle,
onDeleteVisualNovel,
onClaimPuzzlePointIncentive,
onOpenBigFishDetail,
onOpenDraft,
onOpenMatch3DDetail,
onOpenPuzzleDetail,
onOpenSquareHoleDetail,
onOpenVisualNovelDetail,
onEnterPublished,
getWorkState,
puzzleItems,
rpgLibraryEntries,
@@ -237,91 +259,17 @@ export function CustomWorldCreationHub({
[activeFilter, shelfItems],
);
function handleOpenShelfItem(item: CreationWorkShelfItem) {
onOpenShelfItem?.(item);
switch (item.source.kind) {
case 'puzzle':
onOpenPuzzleDetail?.(item.source.item);
return;
case 'visual-novel':
onOpenVisualNovelDetail?.(item.source.item);
return;
case 'big-fish':
onOpenBigFishDetail?.(item.source.item);
return;
case 'match3d':
onOpenMatch3DDetail?.(item.source.item);
return;
case 'square-hole':
onOpenSquareHoleDetail?.(item.source.item);
return;
case 'rpg':
if (item.status === 'draft') {
onOpenDraft(item.source.item);
return;
}
if (item.source.item.profileId) {
onEnterPublished(item.source.item.profileId);
}
}
}
function buildDeleteAction(item: CreationWorkShelfItem) {
if (!item.canDelete) {
return null;
}
switch (item.source.kind) {
case 'puzzle': {
const sourceItem = item.source.item;
return () => {
onDeletePuzzle?.(sourceItem);
};
}
case 'visual-novel': {
const sourceItem = item.source.item;
return () => {
onDeleteVisualNovel?.(sourceItem);
};
}
case 'big-fish': {
const sourceItem = item.source.item;
return () => {
onDeleteBigFish?.(sourceItem);
};
}
case 'match3d': {
const sourceItem = item.source.item;
return () => {
onDeleteMatch3D?.(sourceItem);
};
}
case 'square-hole': {
const sourceItem = item.source.item;
return () => {
onDeleteSquareHole?.(sourceItem);
};
}
case 'rpg': {
const sourceItem = item.source.item;
return () => {
onDeletePublished?.(sourceItem);
};
}
}
return item.actions.delete ?? null;
}
function buildPointIncentiveAction(item: CreationWorkShelfItem) {
if (item.source.kind !== 'puzzle' || !onClaimPuzzlePointIncentive) {
return null;
}
const sourceItem = item.source.item;
return () => {
onClaimPuzzlePointIncentive(sourceItem);
};
return item.actions.claimPointIncentive ?? null;
}
const showStartCard = mode !== 'works-only';
@@ -390,7 +338,10 @@ export function CustomWorldCreationHub({
previousMetricValues={
metricSnapshot[buildWorkMetricCacheItemKey(item)]
}
onOpen={() => handleOpenShelfItem(item)}
onOpen={() => {
onOpenShelfItem?.(item);
item.actions.open();
}}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}
onClaimPointIncentive={buildPointIncentiveAction(item)}
@@ -1,4 +1,4 @@
import { expect, test } from 'vitest';
import { expect, test, vi } from 'vitest';
import { buildCreationWorkShelfItems } from './creationWorkShelf';
@@ -45,3 +45,39 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code',
expect(items[1]?.status).toBe('draft');
expect(items[1]?.publicWorkCode).toBeNull();
});
test('buildCreationWorkShelfItems attaches open and delete actions through shelf adapters', () => {
const onOpenPuzzleDetail = vi.fn();
const onDeletePuzzle = vi.fn();
const puzzleWork = {
workId: 'puzzle:work-action',
profileId: 'puzzle-profile-action',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '动作拼图',
summary: '验证作品架动作 Adapter。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft' as const,
updatedAt: '2026-05-08T00:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
};
const [item] = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [puzzleWork],
onOpenPuzzleDetail,
onDeletePuzzle,
});
item?.actions.open();
item?.actions.delete?.();
expect(onOpenPuzzleDetail).toHaveBeenCalledWith(puzzleWork);
expect(onDeletePuzzle).toHaveBeenCalledWith(puzzleWork);
});

Some files were not shown because too many files have changed in this diff Show More