master #14
@@ -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"
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
# 声控狗叫对战 2D 浏览器游戏设计与实现计划
|
||||
|
||||
## 目标
|
||||
|
||||
基于用户提供的视频:
|
||||
|
||||
`C:\Users\DSK\Videos\一款双方比狗叫的游戏 - 1.一款双方比狗叫的游戏(Av116504192360177,P1).mp4`
|
||||
|
||||
提取其中“双方比狗叫”的核心玩法,并按照 BDD / TDD / DDD 的方法,为 Genarrative 中可运行于浏览器的 2D 游戏方案生成一份可落地设计与实现思路。实现方向遵循仓库内 `game-studio` 插件工作流,默认采用 2D Phaser + TypeScript + Vite + DOM HUD 的浏览器游戏架构。
|
||||
|
||||
本计划仅做方案设计,不直接编码。
|
||||
|
||||
## 当前上下文与输入分析
|
||||
|
||||
### 已识别视频核心画面
|
||||
|
||||
通过抽帧观察,视频中的游戏呈现出以下稳定特征:
|
||||
|
||||
- 画面是横版 2D 手绘舞台,场景包括公园、海边等固定关卡背景。
|
||||
- 双方各有一只狗作为对战角色,站在左右两侧。
|
||||
- 中央有明显倒计时,例如 `30`、`28`。
|
||||
- 顶部有红蓝双方拉锯式能量条 / 进度条。
|
||||
- 中央提示出现:`对着麦克风汪一声`、`用声音大小 + 叫声次数推动能量条!`
|
||||
- 玩家输入不是传统键鼠,而是麦克风声音。
|
||||
- 玩家需要模仿狗叫,系统根据声音大小与叫声次数推动能量条。
|
||||
- 屏幕会根据叫声出现 `BARK`、`WOOF`、`WAN`、`WANGOOF` 等拟声词与冲击波视觉反馈。
|
||||
- 回合结束时,根据能量条偏向或推进结果判定胜负。
|
||||
|
||||
### 提炼出的核心玩法
|
||||
|
||||
这是一个“声控拔河式狗叫对战”小游戏:
|
||||
|
||||
- 两名玩家 / 一名玩家对 AI 分别代表左右两只狗。
|
||||
- 每局限时 30 秒。
|
||||
- 玩家通过麦克风持续发出狗叫声。
|
||||
- 游戏实时分析音量峰值、叫声次数、叫声节奏。
|
||||
- 声音越大、叫声越密集,己方推动力越强。
|
||||
- 顶部能量条在双方推动力差值下左右移动。
|
||||
- 时间结束后,能量条偏向哪一方,哪一方获胜。
|
||||
|
||||
### 需要合理抽象的地方
|
||||
|
||||
视频中存在直播弹幕、贴图、表情包、遮挡层,这些不是游戏本体机制。本方案只吸收游戏本体核心:
|
||||
|
||||
- 双方狗狗对叫
|
||||
- 麦克风输入
|
||||
- 声音强度 + 次数判定
|
||||
- 红蓝拉锯能量条
|
||||
- 限时回合
|
||||
- 夸张拟声词与冲击波反馈
|
||||
|
||||
## game-studio 插件路线
|
||||
|
||||
根据仓库内 `.hermes/plugins/game-studio` 技能:
|
||||
|
||||
- 早期游戏工作先走 `game-studio` 总入口。
|
||||
- 2D 浏览器游戏默认选择 Phaser。
|
||||
- 架构上需要分离 simulation 与 renderer。
|
||||
- HUD / 菜单 / 设置优先使用 DOM overlay,不把密集文字塞进 canvas。
|
||||
- 玩法状态不应由 Phaser Scene 直接持有,Scene 只负责渲染、动画、相机、输入适配。
|
||||
|
||||
因此本方案采用:
|
||||
|
||||
- Runtime:Phaser 3
|
||||
- Language:TypeScript
|
||||
- Build:Vite
|
||||
- UI:React/DOM HUD overlay 或项目现有 DOM UI 层
|
||||
- Audio input:Web Audio API + MediaDevices.getUserMedia
|
||||
- Simulation:纯 TS domain/service 层
|
||||
- Renderer:Phaser Scene 读取 simulation snapshot 并播放动画/特效
|
||||
|
||||
## 游戏概念设计
|
||||
|
||||
### 游戏名建议
|
||||
|
||||
- 中文:`汪汪声浪大作战`
|
||||
- 英文代号:`bark-battle`
|
||||
- Play type ID 建议:`bark-battle`
|
||||
|
||||
### 玩家幻想
|
||||
|
||||
玩家不是通过按键战斗,而是真的对着麦克风“汪汪叫”,把自己的狗狗声浪推向对手。游戏目标是在倒计时结束前用更响、更密集、更有节奏的叫声赢得声浪拔河。
|
||||
|
||||
### 核心动词
|
||||
|
||||
- 叫:对麦克风发出狗叫声。
|
||||
- 推:通过叫声推动能量条。
|
||||
- 压制:让能量条持续向对手方向倾斜。
|
||||
- 爆发:短时间内连续高质量叫声触发冲击波。
|
||||
- 防守:对手强势时通过持续叫声把能量条拉回。
|
||||
|
||||
### 单局流程
|
||||
|
||||
1. 准备阶段
|
||||
- 展示双方狗狗、地图、麦克风权限提示。
|
||||
- 用户授权麦克风。
|
||||
- 系统检测环境噪音并校准阈值。
|
||||
|
||||
2. 倒计时阶段
|
||||
- 3、2、1 或中央 `30` 倒计时开始。
|
||||
- 玩家看到提示:`对着麦克风汪一声`。
|
||||
|
||||
3. 对战阶段
|
||||
- 每帧或固定 tick 采集麦克风音量。
|
||||
- 根据音量峰值与短促叫声次数计算本方 barkPower。
|
||||
- AI 或远端对手产生 opponentPower。
|
||||
- 能量条根据 `playerPower - opponentPower` 拉锯。
|
||||
- 狗狗张嘴动画、拟声词、冲击波按声音强度生成。
|
||||
|
||||
4. 结算阶段
|
||||
- 30 秒结束。
|
||||
- 能量条偏玩家侧则胜利,偏对手侧则失败,接近中线则平局。
|
||||
- 展示叫声次数、最大音量、平均节奏、声浪评分。
|
||||
|
||||
5. 重开 / 返回
|
||||
- 支持再来一局。
|
||||
- 支持返回玩法入口或结果页。
|
||||
|
||||
## 规则设计
|
||||
|
||||
### 关键状态
|
||||
|
||||
```ts
|
||||
type BarkBattlePhase = 'permission' | 'calibration' | 'countdown' | 'playing' | 'finished'
|
||||
|
||||
type BarkBattleSnapshot = {
|
||||
phase: BarkBattlePhase
|
||||
remainingMs: number
|
||||
energy: number // -100 到 100,负数偏对手,正数偏玩家
|
||||
player: BarkSideState
|
||||
opponent: BarkSideState
|
||||
winner: 'player' | 'opponent' | 'draw' | null
|
||||
}
|
||||
|
||||
type BarkSideState = {
|
||||
barkCount: number
|
||||
currentVolume: number
|
||||
recentPeak: number
|
||||
combo: number
|
||||
power: number
|
||||
isBarking: boolean
|
||||
}
|
||||
```
|
||||
|
||||
### 输入判定
|
||||
|
||||
#### 音量采样
|
||||
|
||||
- 使用 Web Audio API 创建 `AnalyserNode`。
|
||||
- 每个 simulation tick 读取频域或时域数据。
|
||||
- 计算 RMS 或 peak volume。
|
||||
- 根据校准后的环境噪音设置动态阈值。
|
||||
|
||||
#### 一次“叫声”的判定
|
||||
|
||||
一次有效叫声建议满足:
|
||||
|
||||
- 音量超过 `barkThreshold`。
|
||||
- 与上一次叫声峰值至少间隔 `minBarkGapMs`,避免持续噪音被无限计数。
|
||||
- 持续时长在合理范围,例如 80ms 到 1200ms。
|
||||
- 可选:频谱能量集中在中高频,不强制做复杂语音识别,MVP 先用音量 + 峰值节奏。
|
||||
|
||||
#### 推动力计算
|
||||
|
||||
```text
|
||||
playerPower = volumeScore * 0.65 + barkRateScore * 0.35 + comboBonus
|
||||
opponentPower = aiPower 或远端玩家 power
|
||||
energyDelta = (playerPower - opponentPower) * deltaTime * balanceFactor
|
||||
energy = clamp(energy + energyDelta, -100, 100)
|
||||
```
|
||||
|
||||
### AI 对手 MVP
|
||||
|
||||
若先做单机浏览器版,右侧对手可由 AI 模拟:
|
||||
|
||||
- 简单难度:周期性小叫,power 低。
|
||||
- 普通难度:有节奏地爆发,power 中等。
|
||||
- 困难难度:根据玩家领先程度自适应追赶,但不得作弊到不可赢。
|
||||
|
||||
后续可扩展为多人实时对战。
|
||||
|
||||
## BDD 行为场景
|
||||
|
||||
### 功能: 麦克风授权与准备
|
||||
|
||||
```gherkin
|
||||
功能: 狗叫对战麦克风准备
|
||||
为了让玩家能用声音参与对战
|
||||
作为浏览器玩家
|
||||
我希望游戏在开局前明确请求麦克风权限并完成环境校准
|
||||
|
||||
场景: 玩家允许麦克风权限后进入准备倒计时
|
||||
假如玩家打开狗叫对战页面
|
||||
当玩家同意浏览器麦克风授权
|
||||
那么系统应进入环境噪音校准阶段
|
||||
而且校准完成后应显示开局倒计时
|
||||
|
||||
场景: 玩家拒绝麦克风权限
|
||||
假如玩家打开狗叫对战页面
|
||||
当玩家拒绝浏览器麦克风授权
|
||||
那么系统应显示无法声控游玩的提示
|
||||
而且应提供重试授权入口
|
||||
而且不应直接开始对战
|
||||
```
|
||||
|
||||
### 功能: 声音推动能量条
|
||||
|
||||
```gherkin
|
||||
功能: 声音大小和叫声次数推动能量条
|
||||
为了复刻双方比狗叫的核心体验
|
||||
作为玩家
|
||||
我希望自己的叫声能实时推动顶部能量条
|
||||
|
||||
场景: 玩家发出一次有效狗叫
|
||||
假如游戏处于 playing 阶段
|
||||
而且麦克风输入音量超过有效叫声阈值
|
||||
当系统检测到一次新的叫声峰值
|
||||
那么玩家叫声次数应增加 1
|
||||
而且玩家狗狗应播放张嘴吠叫动画
|
||||
而且画面应出现拟声词反馈
|
||||
|
||||
场景: 玩家连续大声狗叫压制对手
|
||||
假如游戏处于 playing 阶段
|
||||
而且玩家在短时间内产生多次有效叫声
|
||||
当玩家推动力高于对手推动力
|
||||
那么顶部能量条应向玩家侧移动
|
||||
而且玩家侧声浪特效应增强
|
||||
|
||||
场景: 环境噪音低于阈值不计入叫声
|
||||
假如游戏处于 playing 阶段
|
||||
当麦克风只有低于阈值的背景噪音
|
||||
那么玩家叫声次数不应增加
|
||||
而且能量条不应因为背景噪音明显移动
|
||||
```
|
||||
|
||||
### 功能: 限时胜负结算
|
||||
|
||||
```gherkin
|
||||
功能: 狗叫对战胜负结算
|
||||
为了让单局对抗有明确目标
|
||||
作为玩家
|
||||
我希望倒计时结束后根据能量条位置判定胜负
|
||||
|
||||
场景: 倒计时结束时玩家侧占优
|
||||
假如游戏剩余时间归零
|
||||
而且能量条位于玩家侧
|
||||
当系统进入结算阶段
|
||||
那么系统应判定玩家胜利
|
||||
而且展示玩家叫声次数、最大音量和声浪评分
|
||||
|
||||
场景: 倒计时结束时双方接近平衡
|
||||
假如游戏剩余时间归零
|
||||
而且能量条处于平局阈值范围内
|
||||
当系统进入结算阶段
|
||||
那么系统应判定为平局
|
||||
而且展示再来一局入口
|
||||
```
|
||||
|
||||
### 功能: 移动端与无麦克风降级
|
||||
|
||||
```gherkin
|
||||
功能: 声控游戏移动端与无麦克风降级
|
||||
为了让不同设备玩家都能理解当前状态
|
||||
作为移动端或无麦克风环境玩家
|
||||
我希望系统给出清晰、可操作的降级路径
|
||||
|
||||
场景: 当前浏览器不支持麦克风 API
|
||||
假如玩家设备不支持 getUserMedia
|
||||
当玩家进入狗叫对战页面
|
||||
那么系统应显示设备不支持麦克风输入
|
||||
而且提供返回入口
|
||||
|
||||
场景: 移动端进入对战页面
|
||||
假如玩家使用移动端浏览器
|
||||
当玩家进入狗叫对战页面
|
||||
那么主要能量条、倒计时和狗狗角色应保持可见
|
||||
而且非关键设置应收起到菜单中
|
||||
```
|
||||
|
||||
## DDD 领域划分
|
||||
|
||||
### 领域层:bark-battle domain
|
||||
|
||||
职责:只处理玩法规则,不依赖 Phaser、DOM、Web Audio、后端。
|
||||
|
||||
建议模块:
|
||||
|
||||
- `BarkBattleSession`
|
||||
- 管理 phase、remainingMs、energy、winner。
|
||||
- `BarkDetector`
|
||||
- 根据音量样本判断是否形成一次有效叫声。
|
||||
- `EnergyTugOfWar`
|
||||
- 根据双方 power 更新能量条。
|
||||
- `BarkBattleScoring`
|
||||
- 计算最大音量、叫声次数、combo、评分。
|
||||
- `OpponentStrategy`
|
||||
- 单机 AI 对手策略接口。
|
||||
|
||||
领域规则必须可用纯单元测试验证。
|
||||
|
||||
### 应用层:use case / controller
|
||||
|
||||
职责:编排麦克风输入、simulation tick、AI 对手、结果输出。
|
||||
|
||||
建议用例:
|
||||
|
||||
- `requestMicrophonePermission()`
|
||||
- `calibrateAmbientNoise()`
|
||||
- `startBarkBattleSession()`
|
||||
- `submitAudioSample(sample)`
|
||||
- `tickBarkBattle(deltaMs)`
|
||||
- `finishBarkBattle()`
|
||||
|
||||
### 基础设施层
|
||||
|
||||
职责:浏览器 API 与引擎适配。
|
||||
|
||||
- `BrowserMicrophoneInput`
|
||||
- 封装 `navigator.mediaDevices.getUserMedia`。
|
||||
- 输出 normalized volume samples。
|
||||
- `PhaserBarkBattleScene`
|
||||
- 渲染狗狗、背景、拟声词、冲击波。
|
||||
- 不持有核心玩法规则。
|
||||
- `DomBarkBattleHud`
|
||||
- 展示倒计时、能量条、权限提示、结算面板。
|
||||
|
||||
### 表现层
|
||||
|
||||
- Phaser Canvas:地图、狗狗、声浪、粒子、拟声词。
|
||||
- DOM HUD:顶部能量条、倒计时、权限/结算/设置面板。
|
||||
|
||||
## TDD 落地顺序
|
||||
|
||||
### 第一轮:领域规则 RED-GREEN-REFACTOR
|
||||
|
||||
先写纯 TS 单元测试,不接 Phaser,不接麦克风。
|
||||
|
||||
目标测试:
|
||||
|
||||
- `BarkDetector`:超过阈值且间隔足够时计为一次叫声。
|
||||
- `BarkDetector`:持续噪音不会无限增加叫声次数。
|
||||
- `EnergyTugOfWar`:玩家 power 高于对手时 energy 向玩家侧移动。
|
||||
- `EnergyTugOfWar`:energy 被 clamp 在 -100 到 100。
|
||||
- `BarkBattleSession`:倒计时归零后进入 finished。
|
||||
- `BarkBattleSession`:根据 energy 判定 player/opponent/draw。
|
||||
|
||||
### 第二轮:应用层测试
|
||||
|
||||
- 模拟音频 sample 输入,验证 session snapshot 更新。
|
||||
- 模拟 AI 对手 power,验证能量条拉锯。
|
||||
- 模拟权限失败,验证 phase 不进入 playing。
|
||||
|
||||
### 第三轮:组件 / 集成测试
|
||||
|
||||
- HUD 根据 snapshot 显示倒计时。
|
||||
- HUD 根据 energy 渲染红蓝能量条比例。
|
||||
- 权限拒绝时显示重试入口。
|
||||
- 结算阶段显示胜负与再来一局。
|
||||
|
||||
### 第四轮:浏览器 smoke / playtest
|
||||
|
||||
- 本地启动页面。
|
||||
- 授权麦克风。
|
||||
- 对麦克风发声后看到拟声词与能量条变化。
|
||||
- 移动端宽度下主游戏画面不被 HUD 遮挡。
|
||||
|
||||
## 建议文件结构
|
||||
|
||||
如果作为独立前端玩法原型,可采用:
|
||||
|
||||
```text
|
||||
src/games/bark-battle/
|
||||
domain/
|
||||
BarkBattleSession.ts
|
||||
BarkDetector.ts
|
||||
EnergyTugOfWar.ts
|
||||
BarkBattleScoring.ts
|
||||
OpponentStrategy.ts
|
||||
application/
|
||||
BarkBattleController.ts
|
||||
BrowserMicrophoneInput.ts
|
||||
phaser/
|
||||
BarkBattleScene.ts
|
||||
BarkBattlePreloadScene.ts
|
||||
barkBattleAssets.ts
|
||||
ui/
|
||||
BarkBattleHud.tsx
|
||||
BarkBattleResultPanel.tsx
|
||||
BarkBattlePermissionPanel.tsx
|
||||
tests/
|
||||
BarkDetector.test.ts
|
||||
EnergyTugOfWar.test.ts
|
||||
BarkBattleSession.test.ts
|
||||
```
|
||||
|
||||
如果接入 Genarrative 玩法类型闭环,后续还需要按 `genarrative-play-type-integration` 扩展:
|
||||
|
||||
```text
|
||||
src/components/bark-battle-runtime/BarkBattleRuntimeShell.tsx
|
||||
src/components/bark-battle-result/BarkBattleResultView.tsx
|
||||
src/services/barkBattleRuntimeClient.ts
|
||||
packages/shared/src/contracts/barkBattle.ts
|
||||
server-rs/crates/shared-contracts/src/bark_battle.rs
|
||||
```
|
||||
|
||||
MVP 阶段建议先做浏览器单机 runtime 原型,再决定是否进入创作入口、作品发布、广场和后端持久化。
|
||||
|
||||
## UI / 视觉方向
|
||||
|
||||
### 画面
|
||||
|
||||
- 横版固定舞台。
|
||||
- 左右两只狗对峙。
|
||||
- 背景可先做公园一张图,后续扩展海边、街区等地图。
|
||||
- 狗狗用 2D sprite 或简单骨架帧动画。
|
||||
|
||||
### HUD
|
||||
|
||||
- 顶部:红蓝声浪能量条。
|
||||
- 中央:大号倒计时,只在开局和关键时间突出显示。
|
||||
- 左右:双方狗狗状态,不堆叠复杂面板。
|
||||
- 底部或角落:麦克风状态、小型重试按钮。
|
||||
- 结算:居中弹出简洁面板,显示胜负和关键数据。
|
||||
|
||||
### 动效
|
||||
|
||||
- 叫声触发狗狗张嘴。
|
||||
- 声音越大,拟声词越大,冲击波越宽。
|
||||
- combo 时触发短暂屏幕震动,但不能遮挡能量条。
|
||||
- 尊重 reduced motion,非必要动画可降级。
|
||||
|
||||
## 测试映射
|
||||
|
||||
| BDD 场景 | 测试层级 | 目标文件 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 玩家允许麦克风权限后进入准备倒计时 | application/component | `BarkBattleController.test.ts`, `BarkBattlePermissionPanel.test.tsx` | planned |
|
||||
| 玩家拒绝麦克风权限 | application/component | `BarkBattleController.test.ts`, `BarkBattlePermissionPanel.test.tsx` | planned |
|
||||
| 玩家发出一次有效狗叫 | unit | `BarkDetector.test.ts` | planned |
|
||||
| 玩家连续大声狗叫压制对手 | unit/integration | `EnergyTugOfWar.test.ts`, `BarkBattleController.test.ts` | planned |
|
||||
| 环境噪音低于阈值不计入叫声 | unit | `BarkDetector.test.ts` | planned |
|
||||
| 倒计时结束时玩家侧占优 | unit | `BarkBattleSession.test.ts` | planned |
|
||||
| 倒计时结束时双方接近平衡 | unit | `BarkBattleSession.test.ts` | planned |
|
||||
| 当前浏览器不支持麦克风 API | component | `BarkBattlePermissionPanel.test.tsx` | planned |
|
||||
| 移动端进入对战页面 | visual/smoke | Playwright 或人工 playtest 清单 | planned |
|
||||
|
||||
## 验证命令建议
|
||||
|
||||
具体命令以后续实际落地位置为准,建议包括:
|
||||
|
||||
```bash
|
||||
npm run test -- --run src/games/bark-battle/**/*.test.ts
|
||||
npm run test -- --run src/games/bark-battle/**/*.test.tsx
|
||||
npm run typecheck
|
||||
npm run check:encoding
|
||||
```
|
||||
|
||||
若接入 Genarrative 后端或玩法配置,还需要追加:
|
||||
|
||||
```bash
|
||||
cd server-rs && cargo check -p api-server -p shared-contracts --no-default-features
|
||||
npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts
|
||||
```
|
||||
|
||||
## 实施阶段拆分
|
||||
|
||||
### Phase 0:产品与技术定稿
|
||||
|
||||
- 确认玩法 ID:`bark-battle`。
|
||||
- 确认 MVP 只做单机玩家 vs AI,不做实时多人。
|
||||
- 确认是否只做 runtime 原型,还是接入 Genarrative 创作入口。
|
||||
- 确认是否允许浏览器麦克风权限作为核心输入。
|
||||
|
||||
### Phase 1:纯领域模型
|
||||
|
||||
- 建立 bark-battle domain。
|
||||
- 按 TDD 写 `BarkDetector`、`EnergyTugOfWar`、`BarkBattleSession` 测试。
|
||||
- 实现最小规则让测试通过。
|
||||
|
||||
### Phase 2:麦克风输入适配
|
||||
|
||||
- 封装 Web Audio API。
|
||||
- 支持权限请求、权限失败、环境噪音校准。
|
||||
- 使用 mock input 完成自动化测试,真实麦克风做 smoke。
|
||||
|
||||
### Phase 3:Phaser 2D runtime
|
||||
|
||||
- 新建 Phaser Scene。
|
||||
- 绘制或占位加载公园背景、左右狗狗、声浪特效。
|
||||
- Scene 只消费 snapshot,不写规则。
|
||||
- 接入 DOM HUD。
|
||||
|
||||
### Phase 4:反馈与结算
|
||||
|
||||
- 加入拟声词、冲击波、狗狗张嘴动画。
|
||||
- 加入结算面板。
|
||||
- 加入再来一局与返回入口。
|
||||
|
||||
### Phase 5:Genarrative 集成可选项
|
||||
|
||||
若要正式接入玩法类型:
|
||||
|
||||
- 补 `shared-contracts` 中 bark-battle runtime/result DTO。
|
||||
- 补前端 service 与 runtime shell。
|
||||
- 补入口配置数据库 seed。
|
||||
- 补作品架 / 发布 / 广场链路,若需要持久化成绩或作品。
|
||||
- 按 `genarrative-play-type-integration` 执行完整闭环验证。
|
||||
|
||||
## 风险与权衡
|
||||
|
||||
### 麦克风权限风险
|
||||
|
||||
浏览器麦克风权限受 HTTPS、浏览器策略、用户设置影响。MVP 需要明确:
|
||||
|
||||
- 本地开发可在 localhost 使用。
|
||||
- 线上必须 HTTPS。
|
||||
- 权限拒绝需要可恢复。
|
||||
|
||||
### 声音识别准确性风险
|
||||
|
||||
MVP 不建议做复杂“是否真的是狗叫”的 AI 识别,否则实现成本高、误判多。建议先用:
|
||||
|
||||
- 音量阈值
|
||||
- 峰值次数
|
||||
- 节奏间隔
|
||||
- 环境噪音校准
|
||||
|
||||
后续再考虑加入频谱特征或 ML 分类。
|
||||
|
||||
### 噪音作弊风险
|
||||
|
||||
玩家可以喊叫、拍桌子或播放音频。若是娱乐派对玩法可以接受;若要竞技公平,需要后续加入:
|
||||
|
||||
- 频谱特征
|
||||
- 输入冷却
|
||||
- 异常持续噪音削弱
|
||||
- 本地/服务端反作弊策略
|
||||
|
||||
### 移动端兼容风险
|
||||
|
||||
移动端 Web Audio 可能需要用户手势激活 AudioContext。计划中需把“开始”按钮作为显式用户手势,避免自动启动失败。
|
||||
|
||||
### UI 遮挡风险
|
||||
|
||||
视频原型中的核心可读信息非常少:倒计时、能量条、狗狗、拟声词。实现时应避免把说明文案、复杂面板长期铺在画面上。
|
||||
|
||||
## 开放问题
|
||||
|
||||
1. MVP 是“玩家 vs AI”,还是需要从第一版开始支持双人同屏 / 联机?
|
||||
2. 是否要作为 Genarrative 新玩法入口完整接入,还是先做独立 runtime 原型?
|
||||
3. 是否需要记录成绩、发布作品、进入作品架和广场?
|
||||
4. 狗狗与背景素材是使用临时占位、AI 生成,还是需要复用项目既有素材系统?
|
||||
5. 是否允许游戏强依赖麦克风权限,还是必须提供键盘备用输入?
|
||||
|
||||
## 推荐下一步
|
||||
|
||||
建议下一步先执行 Phase 0 + Phase 1:
|
||||
|
||||
1. 明确 MVP 边界:单机玩家 vs AI。
|
||||
2. 写 `BarkDetector` / `EnergyTugOfWar` / `BarkBattleSession` 的 BDD 对应单元测试。
|
||||
3. 不接 Phaser、不接麦克风,先把核心规则用 TDD 跑通。
|
||||
4. 规则稳定后再接 Web Audio 与 Phaser runtime。
|
||||
@@ -0,0 +1,709 @@
|
||||
# bark-battle 三阶段实施计划:浏览器原型 → AI 创作入口 → 数据库落地
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 按“三阶段”推进 `bark-battle / 汪汪声浪大作战`:第一阶段先做纯浏览器可运行游戏原型并验证玩法跑通;第二阶段接入 Genarrative 创作入口,用 AI 生成可试玩内容;第三阶段再打通后端数据库、发布、成绩和作品闭环。
|
||||
|
||||
**Architecture:** 第一阶段只在前端 runtime 内闭环,优先落 `src/games/bark-battle/` 与直达路由,不依赖后端和 SpacetimeDB。第二阶段在已有创作入口、Agent flow controller、结果页和 runtime shell 上接入 `bark-battle`,AI 只生成配置化草稿,不承接正式业务真相。第三阶段按 `server-rs + Axum + SpacetimeDB` DDD 分层落库,前端只展示后端投影和调用后端 API。
|
||||
|
||||
**Tech Stack:** React 19、TypeScript、Vite、Vitest、Testing Library;第一阶段优先 DOM/Canvas 原型,可在验证玩法后再引入 Phaser 3;后端阶段使用 `server-rs`、Axum、SpacetimeDB、shared-contracts。
|
||||
|
||||
---
|
||||
|
||||
## 0. 当前上下文 / 假设
|
||||
|
||||
- 现有需求与技术文档:
|
||||
- `docs/prd/BARK_BATTLE_BDD_2026-05-11.md`
|
||||
- `docs/technical/BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md`
|
||||
- `docs/technical/BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md`
|
||||
- 用户明确要求阶段顺序:
|
||||
1. 第一阶段:先制作纯浏览器运行的游戏原型,需要测试游戏功能是否能跑通。
|
||||
2. 第二阶段:打通创作入口,使用 AI 赋能游戏内容创作。
|
||||
3. 第三阶段:最后打通数据库落地。
|
||||
- 因此本计划调整原技术方案中的落地优先级:
|
||||
- 第一阶段不新增后端表、不接发布、不接作品架。
|
||||
- 第一阶段可以用 mock / local draft 配置与直达路由 `/bark-battle` 完成 playable prototype。
|
||||
- 第一阶段若 Phaser 依赖尚未安装,优先用 React DOM + Canvas/CSS 2D 原型跑通功能;待核心规则验证后再决定是否引入 Phaser,避免第一阶段被依赖安装和素材管线阻塞。
|
||||
- 当前仓库 `package.json` 还没有 `phaser` 依赖;如实现者选择 Phaser,需要单独评估依赖引入、包体和测试影响。
|
||||
- 本计划只写计划,不直接实现代码。
|
||||
|
||||
## 1. 总体分阶段验收口径
|
||||
|
||||
### Phase 1:纯浏览器游戏原型
|
||||
|
||||
目标:打开本地前端路由即可玩到一局 `bark-battle`,并通过自动测试确认核心规则跑通。
|
||||
|
||||
必须满足:
|
||||
- 可从 `/bark-battle` 进入独立原型页面。
|
||||
- 不登录、不请求后端、不依赖数据库。
|
||||
- 支持开发 mock input:点击/按键/按钮可模拟音量峰值;有真实麦克风时可走 Web Audio。
|
||||
- 能完成:权限/开始 → 校准或 mock 准备 → 倒计时 → 30 秒 playing → 结算 → 再来一局。
|
||||
- 低于阈值输入不计数;有效叫声计数;能量条向玩家或对手移动;结算胜/负/平。
|
||||
- 移动端至少能看到能量条、倒计时、双方狗狗、主要按钮和结算。
|
||||
|
||||
### Phase 2:AI 创作入口
|
||||
|
||||
目标:创作者能从创作中心选择 `bark-battle`,用 AI 生成玩法配置草稿,并进入结果页试玩。
|
||||
|
||||
必须满足:
|
||||
- 后端入口配置中出现 `bark-battle`,按开关展示/可点击。
|
||||
- 前端类型分流、SelectionStage、工作台、结果页、runtime 入口齐全。
|
||||
- AI 生成内容仅限配置化草稿:标题、主题、狗狗外观描述、背景风格、难度、局长、AI 对手参数、提示文案 key 等。
|
||||
- 生成结果可在本地 runtime 中试玩。
|
||||
- 未落库前可先用 session/local state 保存草稿,但要清楚标识为“未发布草稿”。
|
||||
|
||||
### Phase 3:数据库落地与正式作品闭环
|
||||
|
||||
目标:`bark-battle` 草稿、发布态配置、runtime start/finish、成绩和作品级游玩埋点都进入后端 DDD / SpacetimeDB 链路。
|
||||
|
||||
必须满足:
|
||||
- `shared-contracts`、`module-bark-battle`、`spacetime-module`、`spacetime-client`、`api-server` 分层清晰。
|
||||
- 发布为稳定作品 ID,runtime 从后端读取发布态 config。
|
||||
- start 成功写 `work_play_start`:`scope_kind=work`、`scope_id=稳定作品 ID`、metadata 包含 `playType/workId/sourceRoute/userId`。
|
||||
- finish 只上传派生指标,不保存原始麦克风音频、波形或可还原语音内容。
|
||||
- 作品架/广场/分享/排行榜如启用,均来自后端投影。
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase 1:纯浏览器运行游戏原型
|
||||
|
||||
### Task 1.1:补齐阶段边界文档
|
||||
|
||||
**Objective:** 在现有技术方案中明确“先浏览器原型,后 AI 创作,最后数据库”的落地顺序,避免实现时过早接后端。
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/technical/BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md`
|
||||
- Modify: `docs/prd/BARK_BATTLE_BDD_2026-05-11.md`
|
||||
|
||||
**Steps:**
|
||||
1. 在 runtime 技术方案中新增“三阶段落地顺序”小节。
|
||||
2. 明确 Phase 1 不接后端、不接数据库、不接创作入口事实源。
|
||||
3. 在 BDD 中补充“浏览器原型 smoke”验收场景。
|
||||
4. 运行:
|
||||
```bash
|
||||
npm run check:encoding -- docs/prd/BARK_BATTLE_BDD_2026-05-11.md docs/technical/BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**Expected:** 编码检查和 diff 空白检查通过。
|
||||
|
||||
### Task 1.2:建立 Phase 1 目录骨架和类型
|
||||
|
||||
**Objective:** 建立不依赖 React/DOM/Web Audio 的核心类型,后续所有测试和 UI 都围绕这些类型。
|
||||
|
||||
**Files:**
|
||||
- Create: `src/games/bark-battle/domain/BarkBattleTypes.ts`
|
||||
- Create: `src/games/bark-battle/application/BarkBattleConfig.ts`
|
||||
|
||||
**Key design:**
|
||||
- `BarkBattlePhase = 'permission' | 'calibration' | 'countdown' | 'playing' | 'finished' | 'unavailable'`
|
||||
- `MicrophoneFailureReason` 覆盖已有文档中的 9 类失败原因。
|
||||
- `BarkBattleSnapshot` 包含 `phase/uiState/errorReason/statusMessageKey/remainingMs/energy/player/opponent/winner/result/lastEvents`。
|
||||
- `BarkBattleConfig` 包含 `roundDurationMs/drawThreshold/minBarkGapMs/minBarkDurationMs/maxBarkDurationMs/balanceFactor/calibrationMaxWaitMs`。
|
||||
|
||||
**Tests:**
|
||||
- 本任务可先不写运行时逻辑,但需要让 typecheck 能引用这些类型。
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run check:encoding -- src/games/bark-battle/domain/BarkBattleTypes.ts src/games/bark-battle/application/BarkBattleConfig.ts
|
||||
```
|
||||
|
||||
### Task 1.3:TDD 实现叫声检测 BarkDetector
|
||||
|
||||
**Objective:** 用纯函数/纯类把音频样本转换为有效叫声事件。
|
||||
|
||||
**Files:**
|
||||
- Create: `src/games/bark-battle/domain/BarkDetector.ts`
|
||||
- Create: `src/games/bark-battle/domain/__tests__/BarkDetector.test.ts`
|
||||
|
||||
**Test cases:**
|
||||
1. 超过阈值、持续时长合规、间隔足够时计为一次有效叫声。
|
||||
2. 持续噪音不在每个 tick 无限计数。
|
||||
3. 低于阈值的背景噪音不计数。
|
||||
4. `minBarkGapMs` 内连续峰值不重复计数。
|
||||
5. 过短脉冲不计数;过长持续声削弱为单段输入。
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
npm run test -- --run src/games/bark-battle/domain/__tests__/BarkDetector.test.ts
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### Task 1.4:TDD 实现能量条 EnergyTugOfWar
|
||||
|
||||
**Objective:** 验证玩家/对手推动力能稳定改变 `energy`,并 clamp 到 `-100..100`。
|
||||
|
||||
**Files:**
|
||||
- Create: `src/games/bark-battle/domain/EnergyTugOfWar.ts`
|
||||
- Create: `src/games/bark-battle/domain/__tests__/EnergyTugOfWar.test.ts`
|
||||
|
||||
**Test cases:**
|
||||
1. 玩家 power 高于对手时 `energy` 增加。
|
||||
2. 对手 power 高于玩家时 `energy` 减少。
|
||||
3. energy 不超过 `100`。
|
||||
4. energy 不低于 `-100`。
|
||||
5. power 相等时变化不超过浮点误差。
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
npm run test -- --run src/games/bark-battle/domain/__tests__/EnergyTugOfWar.test.ts
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### Task 1.5:TDD 实现单局状态机 BarkBattleSession
|
||||
|
||||
**Objective:** 跑通 permission/calibration/countdown/playing/finished/unavailable 状态流转和结算。
|
||||
|
||||
**Files:**
|
||||
- Create: `src/games/bark-battle/domain/BarkBattleSession.ts`
|
||||
- Create: `src/games/bark-battle/domain/BarkBattleScoring.ts`
|
||||
- Create: `src/games/bark-battle/domain/OpponentStrategy.ts`
|
||||
- Create: `src/games/bark-battle/domain/__tests__/BarkBattleSession.test.ts`
|
||||
- Create: `src/games/bark-battle/domain/__tests__/BarkBattleScoring.test.ts`
|
||||
|
||||
**Test cases:**
|
||||
1. 校准完成后进入 countdown。
|
||||
2. countdown 结束后进入 playing。
|
||||
3. playing 中 `remainingMs` 随 tick 递减。
|
||||
4. `remainingMs <= 0` 后进入 finished。
|
||||
5. `energy > drawThreshold` 判定玩家胜。
|
||||
6. `energy < -drawThreshold` 判定对手胜。
|
||||
7. `abs(energy) <= drawThreshold` 判定平局。
|
||||
8. finished 后新输入不再改变本局计数和能量。
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
npm run test -- --run src/games/bark-battle/domain/__tests__/BarkBattleSession.test.ts src/games/bark-battle/domain/__tests__/BarkBattleScoring.test.ts
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### Task 1.6:实现 mock-first Application Controller
|
||||
|
||||
**Objective:** 不依赖真实麦克风,先用 mock audio sample 驱动完整 snapshot。
|
||||
|
||||
**Files:**
|
||||
- Create: `src/games/bark-battle/application/BarkBattleController.ts`
|
||||
- Create: `src/games/bark-battle/application/BarkBattleSnapshotStore.ts`
|
||||
- Create: `src/games/bark-battle/application/__tests__/BarkBattleController.test.ts`
|
||||
|
||||
**Behavior:**
|
||||
- `startWithMockInput()` 进入校准完成或直接 countdown。
|
||||
- `submitMockSample(sample)` 更新玩家输入。
|
||||
- `tick(deltaMs)` 推进对手、能量条、倒计时。
|
||||
- `restart()` 重置状态。
|
||||
- `failMicrophone(reason)` 进入 `phase: 'unavailable'`,并设置 `errorReason/statusMessageKey`。
|
||||
|
||||
**Test cases:**
|
||||
1. mock start 后能进入 countdown/playing。
|
||||
2. 提交 mock 峰值后 bark count 增加。
|
||||
3. tick 后 energy 可变化。
|
||||
4. finish 后生成 result。
|
||||
5. `failMicrophone('permission-denied')` 不进入 playing。
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
npm run test -- --run src/games/bark-battle/application/__tests__/BarkBattleController.test.ts
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### Task 1.7:实现浏览器原型 UI Shell(不接平台)
|
||||
|
||||
**Objective:** 提供 `/bark-battle` 可访问的 playable prototype。
|
||||
|
||||
**Files:**
|
||||
- Create: `src/BarkBattlePlaygroundApp.tsx`
|
||||
- Create: `src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx`
|
||||
- Create: `src/games/bark-battle/ui/BarkBattleHud.tsx`
|
||||
- Create: `src/games/bark-battle/ui/BarkBattleResultPanel.tsx`
|
||||
- Create: `src/games/bark-battle/ui/BarkBattleHud.css`
|
||||
- Modify: `src/routing/appRoutes.tsx`
|
||||
|
||||
**Behavior:**
|
||||
- 新增路由匹配:`/bark-battle`。
|
||||
- 首屏只有清爽开始面板,不常驻大段规则。
|
||||
- 提供开发原型按钮:开始、模拟叫声、模拟对手增强、再来一局。
|
||||
- playing 画面展示:顶部能量条、倒计时、玩家/对手狗狗、叫声次数、麦克风/mock 状态。
|
||||
- 结算面板独立居中,不追加在当前面板下方。
|
||||
|
||||
**UI constraints:**
|
||||
- 移动端优先。
|
||||
- 正常 playing 阶段不在 playfield 常驻规则说明。
|
||||
- 大动效不遮挡顶部能量条和倒计时。
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
npm run test -- --run src/games/bark-battle/ui/**/*.test.tsx
|
||||
npm run typecheck
|
||||
npm run dev:web
|
||||
# 手动 smoke: 访问 /bark-battle → 开始 → 模拟叫声 → energy 变化 → 结算 → 再来一局
|
||||
```
|
||||
|
||||
### Task 1.8:实现 HUD 组件测试
|
||||
|
||||
**Objective:** 自动验证核心 UI 状态,不只依赖人工试玩。
|
||||
|
||||
**Files:**
|
||||
- Create: `src/games/bark-battle/ui/__tests__/BarkBattleHud.test.tsx`
|
||||
- Create: `src/games/bark-battle/ui/__tests__/BarkBattleResultPanel.test.tsx`
|
||||
|
||||
**Test cases:**
|
||||
1. playing 阶段展示倒计时和能量条。
|
||||
2. energy 正值时玩家侧占比更大。
|
||||
3. energy 负值时对手侧占比更大。
|
||||
4. permission-denied 展示重试授权入口。
|
||||
5. unsupported 不展示开始声控按钮。
|
||||
6. finished 展示胜负、叫声次数、再来一局。
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
npm run test -- --run src/games/bark-battle/ui/__tests__/BarkBattleHud.test.tsx src/games/bark-battle/ui/__tests__/BarkBattleResultPanel.test.tsx
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### Task 1.9:接入真实 Web Audio(可晚于 mock 原型)
|
||||
|
||||
**Objective:** 在支持麦克风的浏览器中真实采样并驱动 controller,同时保留 mock fallback 便于测试。
|
||||
|
||||
**Files:**
|
||||
- Create: `src/games/bark-battle/infrastructure/BrowserMicrophoneInput.ts`
|
||||
- Create: `src/games/bark-battle/infrastructure/AudioAnalyserSampler.ts`
|
||||
- Create: `src/games/bark-battle/infrastructure/MicrophonePermission.ts`
|
||||
- Create: `src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts`
|
||||
- Create: `src/games/bark-battle/infrastructure/__tests__/AudioAnalyserSampler.test.ts`
|
||||
|
||||
**Behavior:**
|
||||
- 用户点击开始后才请求麦克风。
|
||||
- 用户手势后创建/resume `AudioContext`。
|
||||
- 输出归一化 `BarkAudioSample`。
|
||||
- 捕获并映射:unsupported、permission-denied、non-secure-context、not-found、not-readable、audio-context-blocked、unknown。
|
||||
- stop/restart/page unload 时停止 tracks。
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
npm run test -- --run src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts src/games/bark-battle/infrastructure/__tests__/AudioAnalyserSampler.test.ts
|
||||
npm run typecheck
|
||||
npm run dev:web
|
||||
# 手动 smoke: 真实麦克风授权 → 校准 → 发声 → 结算
|
||||
```
|
||||
|
||||
### Task 1.10:Phase 1 收口验证
|
||||
|
||||
**Objective:** 确认“纯浏览器原型”已经可以交给产品/测试试玩。
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
npm run test -- --run src/games/bark-battle/domain/**/*.test.ts src/games/bark-battle/application/**/*.test.ts src/games/bark-battle/infrastructure/**/*.test.ts src/games/bark-battle/ui/**/*.test.tsx
|
||||
npm run typecheck
|
||||
npm run lint:eslint
|
||||
npm run check:encoding
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
**Manual smoke checklist:**
|
||||
- [ ] `/bark-battle` 能打开。
|
||||
- [ ] mock 模式可完整完成一局。
|
||||
- [ ] 真实麦克风模式可授权、校准、发声、结算。
|
||||
- [ ] 拒绝权限后不会进入 playing。
|
||||
- [ ] 移动端窄屏能看到核心信息并能点击主要按钮。
|
||||
- [ ] 再来一局不会继承上一局 energy/barkCount/result。
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 2:打通创作入口,用 AI 赋能内容创作
|
||||
|
||||
### Task 2.1:定义 `bark-battle` 草稿契约(前端本地版)
|
||||
|
||||
**Objective:** 在接后端前,先定义 AI 可生成的 runtime draft shape。
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/shared/src/contracts/barkBattle.ts`(临时前端共享类型,后端阶段再对齐 Rust shared-contracts)
|
||||
- Create: `src/services/bark-battle-creation/barkBattleDraftDefaults.ts`
|
||||
- Create: `src/services/bark-battle-creation/barkBattleDraftValidation.ts`
|
||||
|
||||
**Draft fields:**
|
||||
- `title`
|
||||
- `description`
|
||||
- `themePrompt`
|
||||
- `playerDogName`
|
||||
- `opponentDogName`
|
||||
- `backgroundStyle`
|
||||
- `difficulty`
|
||||
- `roundDurationMs`
|
||||
- `drawThreshold`
|
||||
- `opponentConfig`
|
||||
- `audioSensitivityPreset`
|
||||
- `visualStyle`
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
npm run test -- --run src/services/bark-battle-creation/**/*.test.ts
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### Task 2.2:新增创作入口配置
|
||||
|
||||
**Objective:** 让 `bark-battle` 出现在创作中心入口中,但可通过后端入口配置开关控制。
|
||||
|
||||
**Files likely to change:**
|
||||
- `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
|
||||
- `server-rs/crates/module-runtime/src/domain.rs`
|
||||
- `server-rs/crates/module-runtime/src/application.rs`
|
||||
- `server-rs/crates/shared-contracts/src/creation_entry_config.rs`
|
||||
- `src/components/platform-entry/platformEntryCreationTypes.ts`
|
||||
- `src/components/platform-entry/platformEntryCreationTypes.test.ts`
|
||||
|
||||
**Plan:**
|
||||
1. 在入口 seed 中新增 `bark-battle`,首轮可设:`visible: true`、`open: true`(若需要灰度则 `open: false`)。
|
||||
2. 前端展示派生只消费 API 返回,不恢复旧静态入口事实源。
|
||||
3. 更新排序和锁定态测试。
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts
|
||||
npm run typecheck
|
||||
cd server-rs && cargo check -p api-server -p spacetime-module --no-default-features
|
||||
```
|
||||
|
||||
### Task 2.3:扩展 SelectionStage 与流程分流
|
||||
|
||||
**Objective:** 点击 `bark-battle` 入口后进入对应创作工作台。
|
||||
|
||||
**Files likely to change:**
|
||||
- `src/components/platform-entry/platformEntryTypes.ts`
|
||||
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||
- `src/components/platform-entry/usePlatformCreationAgentFlowController.ts`(如复用通用 agent flow)
|
||||
|
||||
**Stages:**
|
||||
- `bark-battle-agent-workspace`
|
||||
- `bark-battle-generating`(可选)
|
||||
- `bark-battle-result`
|
||||
- `bark-battle-runtime`
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
npm run test -- src/components/platform-entry/**/*.test.tsx src/components/platform-entry/**/*.test.ts
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### Task 2.4:实现 AI 创作工作台
|
||||
|
||||
**Objective:** 用对话式或表单式输入生成 `BarkBattleDraft`。
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/bark-battle-creation/BarkBattleAgentWorkspace.tsx`
|
||||
- Create: `src/services/bark-battle-creation/barkBattleCreationClient.ts`
|
||||
- Create: `src/services/bark-battle-creation/barkBattlePromptBuilder.ts`
|
||||
- Create: `src/services/bark-battle-creation/__tests__/barkBattlePromptBuilder.test.ts`
|
||||
|
||||
**Behavior:**
|
||||
- 用户输入一句主题,例如“柴犬在赛博公园比谁叫得响”。
|
||||
- AI 返回结构化草稿。
|
||||
- 前端校验并填默认值,不让非法 roundDuration/difficulty 进入 runtime。
|
||||
- 错误时保留用户输入和已生成草稿。
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
npm run test -- --run src/services/bark-battle-creation/**/*.test.ts
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### Task 2.5:实现结果页与试玩入口
|
||||
|
||||
**Objective:** AI 草稿生成后可查看、返回编辑、进入 Phase 1 runtime 试玩。
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/bark-battle-result/BarkBattleResultView.tsx`
|
||||
- Create: `src/components/bark-battle-result/BarkBattleResultView.test.tsx`
|
||||
- Modify: `src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx`(允许传入 draft config)
|
||||
|
||||
**Behavior:**
|
||||
- 展示标题、主题、狗狗名、背景风格、难度、局长。
|
||||
- 提供“返回编辑”“试玩”按钮。
|
||||
- 暂不展示发布按钮,或发布按钮显示为后端阶段能力。
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
npm run test -- --run src/components/bark-battle-result/BarkBattleResultView.test.tsx
|
||||
npm run typecheck
|
||||
npm run dev:web
|
||||
# 手动 smoke: 创作入口 → AI 草稿 → 结果页 → 试玩 → 返回编辑
|
||||
```
|
||||
|
||||
### Task 2.6:Phase 2 收口验证
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/bark-battle-result/BarkBattleResultView.test.tsx src/services/bark-battle-creation/**/*.test.ts src/games/bark-battle/**/*.test.ts src/games/bark-battle/**/*.test.tsx
|
||||
npm run typecheck
|
||||
npm run lint:eslint
|
||||
npm run check:encoding
|
||||
```
|
||||
|
||||
**Manual smoke checklist:**
|
||||
- [ ] 创作中心展示 `bark-battle`。
|
||||
- [ ] 点击入口进入工作台。
|
||||
- [ ] AI 可生成草稿。
|
||||
- [ ] 草稿结果页可展示并返回编辑。
|
||||
- [ ] 试玩使用草稿配置影响 runtime 表现。
|
||||
- [ ] 未接数据库前不会假装发布成功。
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 3:数据库落地与正式作品闭环
|
||||
|
||||
### Task 3.1:补齐 shared contracts
|
||||
|
||||
**Objective:** 前后端共享 bark-battle DTO,避免前端手写正式契约漂移。
|
||||
|
||||
**Files likely to change:**
|
||||
- `server-rs/crates/shared-contracts/src/bark_battle.rs`
|
||||
- `server-rs/crates/shared-contracts/src/lib.rs`
|
||||
- `packages/shared/src/contracts/barkBattle.ts`
|
||||
|
||||
**DTO:**
|
||||
- `BarkBattleDraft`
|
||||
- `BarkBattlePublishedConfig`
|
||||
- `CreateBarkBattleSessionRequest/Response`
|
||||
- `BarkBattleRuntimeStartRequest/Response`
|
||||
- `BarkBattleRuntimeFinishRequest/Response`
|
||||
- `BarkBattleRunResult`
|
||||
- `BarkBattleScoreSummary`
|
||||
- `BarkBattleLeaderboardEntry`
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
npm run typecheck
|
||||
cd server-rs && cargo check -p shared-contracts --no-default-features
|
||||
```
|
||||
|
||||
### Task 3.2:新增 `module-bark-battle` 纯领域模块
|
||||
|
||||
**Objective:** 后端正式分数、配置校验、提交合法性不写在 api-server handler 里。
|
||||
|
||||
**Files:**
|
||||
- Create: `server-rs/crates/module-bark-battle/`
|
||||
- Modify: `server-rs/Cargo.toml`
|
||||
|
||||
**Responsibilities:**
|
||||
- 配置合法性校验。
|
||||
- run start/finish 状态约束。
|
||||
- 派生指标范围校验。
|
||||
- 分数与排行榜排序分计算。
|
||||
- 不接 Axum、不接 SpacetimeDB、不接 HTTP。
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
cd server-rs && cargo test -p module-bark-battle --no-default-features
|
||||
cd server-rs && cargo check -p module-bark-battle --no-default-features
|
||||
```
|
||||
|
||||
### Task 3.3:SpacetimeDB 表、reducer、migration
|
||||
|
||||
**Objective:** 保存草稿、发布态配置、run、result、leaderboard 投影。
|
||||
|
||||
**Files likely to change:**
|
||||
- `server-rs/crates/spacetime-module/src/runtime/bark_battle.rs`(或按现有模块目录命名)
|
||||
- `server-rs/crates/spacetime-module/src/migration.rs`
|
||||
- `server-rs/crates/spacetime-module/src/lib.rs`
|
||||
- 生成绑定目录(通过命令生成,不手改生成物)
|
||||
|
||||
**Tables draft:**
|
||||
- `bark_battle_draft`
|
||||
- `bark_battle_published_config`
|
||||
- `bark_battle_run`
|
||||
- `bark_battle_run_result`
|
||||
- `bark_battle_leaderboard_entry`
|
||||
|
||||
**Reducers/procedures:**
|
||||
- `create_bark_battle_draft`
|
||||
- `publish_bark_battle_config`
|
||||
- `start_bark_battle_run`
|
||||
- `finish_bark_battle_run`
|
||||
- `list_bark_battle_leaderboard`
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
npm run spacetime:generate
|
||||
cd server-rs && cargo check -p spacetime-module --no-default-features
|
||||
npm run check:server-rs-ddd
|
||||
```
|
||||
|
||||
### Task 3.4:spacetime-client facade
|
||||
|
||||
**Objective:** api-server 通过 facade 调用 SpacetimeDB,不直接散落 reducer 细节。
|
||||
|
||||
**Files likely to change:**
|
||||
- `server-rs/crates/spacetime-client/src/runtime.rs`
|
||||
- `server-rs/crates/spacetime-client/src/mapper.rs`
|
||||
- `server-rs/crates/spacetime-client/src/lib.rs`
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
cd server-rs && cargo check -p spacetime-client --no-default-features
|
||||
```
|
||||
|
||||
### Task 3.5:api-server BFF 路由
|
||||
|
||||
**Objective:** 提供创作、发布态 runtime start/finish、leaderboard API。
|
||||
|
||||
**Files likely to change:**
|
||||
- `server-rs/crates/api-server/src/bark_battle.rs`
|
||||
- `server-rs/crates/api-server/src/main.rs` 或路由注册文件
|
||||
|
||||
**Routes draft:**
|
||||
- `POST /api/bark-battle/sessions`
|
||||
- `GET /api/bark-battle/sessions/:sessionId`
|
||||
- `POST /api/bark-battle/runtime/start`
|
||||
- `POST /api/bark-battle/runtime/finish`
|
||||
- `GET /api/bark-battle/works/:workId/runtime-config`
|
||||
- `GET /api/bark-battle/works/:workId/leaderboard`
|
||||
|
||||
**Tracking:**
|
||||
- runtime start 成功后主动写 `work_play_start`。
|
||||
- `scope_kind=work`。
|
||||
- `scope_id=稳定作品 ID`。
|
||||
- metadata 包含 `playType=bark-battle`、`workId`、`sourceRoute`、`userId`。
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
npm run api-server
|
||||
# 另一个终端检查 /healthz,并执行对应 API smoke
|
||||
cd server-rs && cargo check -p api-server --no-default-features
|
||||
```
|
||||
|
||||
### Task 3.6:前端正式 client 与 runtime 切换
|
||||
|
||||
**Objective:** runtime 从本地草稿模式升级为可读取后端发布态 config,并提交正式派生结果。
|
||||
|
||||
**Files likely to change:**
|
||||
- Create: `src/services/bark-battle-runtime/barkBattleRuntimeClient.ts`
|
||||
- Create: `src/services/bark-battle-works/barkBattleWorksClient.ts`
|
||||
- Modify: `src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx`
|
||||
- Modify: `src/components/bark-battle-result/BarkBattleResultView.tsx`
|
||||
- Modify: `src/components/custom-world-home/creationWorkShelf.ts`
|
||||
- Modify: `src/components/custom-world-home/CustomWorldCreationHub.tsx`
|
||||
- Modify: `src/services/publicWorkCode.ts`
|
||||
|
||||
**Behavior:**
|
||||
- 本地 preview 仍可使用 draft config。
|
||||
- 正式作品 runtime 必须先调用 start API,拿 run token/session。
|
||||
- finish 只提交派生 metrics。
|
||||
- 发布后刷新作品架/广场。
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
npm run test -- src/services/bark-battle-runtime/**/*.test.ts src/games/bark-battle/**/*.test.ts src/games/bark-battle/**/*.test.tsx
|
||||
npm run typecheck
|
||||
npm run check:encoding
|
||||
```
|
||||
|
||||
### Task 3.7:Phase 3 收口验证
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
npm run test -- src/games/bark-battle/**/*.test.ts src/games/bark-battle/**/*.test.tsx src/services/bark-battle-runtime/**/*.test.ts src/services/bark-battle-creation/**/*.test.ts
|
||||
npm run typecheck
|
||||
npm run lint:eslint
|
||||
npm run check:encoding
|
||||
npm run check:server-rs-ddd
|
||||
cd server-rs && cargo test -p module-bark-battle --no-default-features
|
||||
cd server-rs && cargo check -p api-server -p spacetime-module -p spacetime-client -p shared-contracts --no-default-features
|
||||
npm run api-server
|
||||
```
|
||||
|
||||
**Manual smoke checklist:**
|
||||
- [ ] 创作者生成并发布 bark-battle 作品。
|
||||
- [ ] 玩家从作品页进入 runtime。
|
||||
- [ ] start API 成功并写 `work_play_start`。
|
||||
- [ ] 浏览器本地完成一局。
|
||||
- [ ] finish API 只上传派生指标。
|
||||
- [ ] 成绩/排行榜/作品架刷新来自后端投影。
|
||||
- [ ] 拒绝麦克风权限时不会创建非法 finished result。
|
||||
|
||||
---
|
||||
|
||||
## 5. 文件清单总览
|
||||
|
||||
### Phase 1 likely files
|
||||
|
||||
- `src/routing/appRoutes.tsx`
|
||||
- `src/BarkBattlePlaygroundApp.tsx`
|
||||
- `src/games/bark-battle/domain/BarkBattleTypes.ts`
|
||||
- `src/games/bark-battle/domain/BarkDetector.ts`
|
||||
- `src/games/bark-battle/domain/EnergyTugOfWar.ts`
|
||||
- `src/games/bark-battle/domain/BarkBattleSession.ts`
|
||||
- `src/games/bark-battle/domain/BarkBattleScoring.ts`
|
||||
- `src/games/bark-battle/domain/OpponentStrategy.ts`
|
||||
- `src/games/bark-battle/application/BarkBattleConfig.ts`
|
||||
- `src/games/bark-battle/application/BarkBattleController.ts`
|
||||
- `src/games/bark-battle/application/BarkBattleSnapshotStore.ts`
|
||||
- `src/games/bark-battle/infrastructure/BrowserMicrophoneInput.ts`
|
||||
- `src/games/bark-battle/infrastructure/AudioAnalyserSampler.ts`
|
||||
- `src/games/bark-battle/infrastructure/MicrophonePermission.ts`
|
||||
- `src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx`
|
||||
- `src/games/bark-battle/ui/BarkBattleHud.tsx`
|
||||
- `src/games/bark-battle/ui/BarkBattleResultPanel.tsx`
|
||||
- `src/games/bark-battle/ui/BarkBattleHud.css`
|
||||
|
||||
### Phase 2 likely files
|
||||
|
||||
- `packages/shared/src/contracts/barkBattle.ts`
|
||||
- `src/components/platform-entry/platformEntryTypes.ts`
|
||||
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||
- `src/components/platform-entry/platformEntryCreationTypes.ts`
|
||||
- `src/components/bark-battle-creation/BarkBattleAgentWorkspace.tsx`
|
||||
- `src/components/bark-battle-result/BarkBattleResultView.tsx`
|
||||
- `src/services/bark-battle-creation/*`
|
||||
|
||||
### Phase 3 likely files
|
||||
|
||||
- `server-rs/crates/shared-contracts/src/bark_battle.rs`
|
||||
- `server-rs/crates/module-bark-battle/*`
|
||||
- `server-rs/crates/spacetime-module/src/runtime/bark_battle.rs`
|
||||
- `server-rs/crates/spacetime-module/src/migration.rs`
|
||||
- `server-rs/crates/spacetime-client/src/runtime.rs`
|
||||
- `server-rs/crates/spacetime-client/src/mapper.rs`
|
||||
- `server-rs/crates/api-server/src/bark_battle.rs`
|
||||
- `src/services/bark-battle-runtime/*`
|
||||
- `src/services/bark-battle-works/*`
|
||||
- `src/components/custom-world-home/creationWorkShelf.ts`
|
||||
- `src/services/publicWorkCode.ts`
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险、取舍与开放问题
|
||||
|
||||
### 风险
|
||||
|
||||
1. **麦克风权限和移动端 AudioContext 差异大。** 需要 mock input 保底,否则自动化和本地开发会被真实设备阻塞。
|
||||
2. **第一阶段过早引入 Phaser 可能拖慢验证。** 当前仓库没有 `phaser` 依赖;建议先用 DOM/Canvas 跑通玩法,再决定是否引入 Phaser。
|
||||
3. **AI 草稿和正式发布配置容易漂移。** Phase 2 临时 TS 类型必须在 Phase 3 与 Rust shared-contracts 对齐。
|
||||
4. **不能保存原始音频。** 后端阶段只能保存派生指标,任何音频片段、波形、频谱明细都不应落库。
|
||||
5. **入口配置事实源在后端/SpacetimeDB。** Phase 2 接入口时不要恢复旧前端静态入口配置。
|
||||
|
||||
### 取舍
|
||||
|
||||
- Phase 1 先把“游戏是否好玩、功能是否跑通”作为第一目标,不追求正式作品闭环。
|
||||
- Phase 2 让 AI 生成内容配置,而不是让 AI 直接生成任意代码或不受控规则。
|
||||
- Phase 3 再把正式业务真相交给后端,避免前端 runtime 先背上发布、成绩、排行榜的复杂度。
|
||||
|
||||
### 开放问题
|
||||
|
||||
1. Phase 1 是否必须使用 Phaser?如果只是验证玩法,可先使用 DOM/CSS/Canvas 原型,后续再替换 renderer。
|
||||
2. `bark-battle` 的正式中文名是否固定为“汪汪声浪大作战”?如果名称要改,需先统一文档、入口配置和分享标题。
|
||||
3. AI 创作阶段是否需要生成图片/狗狗视觉资产,还是只生成风格描述和使用占位素材?
|
||||
4. 是否需要排行榜作为 Phase 3 必选,还是作为数据库落地后的增强项?
|
||||
5. 真实麦克风 smoke 需要哪些目标设备:Chrome 桌面、Android Chrome、iOS Safari 是否都纳入首批验收?
|
||||
|
||||
---
|
||||
|
||||
## 7. 建议执行方式
|
||||
|
||||
1. 先按 Phase 1 执行,且每个 domain/application task 坚持 TDD:先失败测试,再实现。
|
||||
2. Phase 1 合并前不要接数据库,不要新增后端表,不要把入口配置切到 open。
|
||||
3. Phase 1 验证通过后,让产品/团队试玩 `/bark-battle`,确认玩法数值和 UI 方向。
|
||||
4. 再进入 Phase 2,把 AI 创作工作台接到同一个 runtime draft config。
|
||||
5. 最后进入 Phase 3,按后端 DDD 文档做数据库、发布、成绩和追踪闭环。
|
||||
|
||||
BIN
.hermes/plans/frame_003.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
.hermes/plans/frame_010.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
.hermes/plans/frame_020.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
.hermes/plans/frame_035.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
14
AGENTS.md
@@ -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
docs/agents/domain.md
Normal 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
docs/agents/issue-tracker.md
Normal 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
docs/agents/triage-labels.md
Normal 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
docs/prd/BARK_BATTLE_BDD_2026-05-11.md
Normal 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 生成,还是复用项目现有素材管线?
|
||||
@@ -0,0 +1,729 @@
|
||||
# bark-battle 2D Runtime 前端技术方案(2026-05-11)
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
本方案基于 `.hermes/plans/2026-05-11_144229-bark-battle-2d-game-bdd-ddd-tdd-plan.md`,为“汪汪声浪大作战 / bark-battle”细化前端与浏览器游戏 runtime 的技术实现路线。
|
||||
|
||||
本任务只产出技术方案,不直接实现代码。后续编码应以本文作为 runtime 层设计约束,再按 TDD 小步落地。
|
||||
|
||||
### 1.1 玩法定位
|
||||
|
||||
`bark-battle` 是一个声控拔河式 2D 浏览器小游戏:玩家对麦克风发出狗叫声,系统根据音量峰值、有效叫声次数和节奏计算本方声浪推动力,在限时 30 秒内推动顶部红蓝能量条,时间结束后按能量条偏向判定胜负。
|
||||
|
||||
### 1.2 本文范围
|
||||
|
||||
本文覆盖:
|
||||
|
||||
- Phaser + TypeScript + Vite 栈选择
|
||||
- simulation / render / HUD 边界
|
||||
- 建议目录结构
|
||||
- 核心 domain 类型
|
||||
- Web Audio 输入适配
|
||||
- Phaser Scene 切分
|
||||
- DOM HUD 设计
|
||||
- 移动端与权限降级
|
||||
- 测试与验证命令
|
||||
|
||||
本文不覆盖:
|
||||
|
||||
- 后端表结构、持久化成绩、作品发布、广场接入
|
||||
- 实时多人对战协议
|
||||
- 复杂 AI 狗叫识别模型
|
||||
- 美术素材正式生产流程
|
||||
|
||||
## 2. 技术栈选择
|
||||
|
||||
### 2.1 推荐栈
|
||||
|
||||
```text
|
||||
Runtime Renderer: Phaser 3
|
||||
Language: TypeScript
|
||||
Build: Vite
|
||||
Host UI: React / DOM overlay
|
||||
Audio Input: Web Audio API + MediaDevices.getUserMedia
|
||||
Test: Vitest + Testing Library + 浏览器 smoke / Playwright 可选
|
||||
```
|
||||
|
||||
### 2.2 选择理由
|
||||
|
||||
1. 玩法是横版 2D 舞台,核心表现是狗狗 sprite、声浪、拟声词、粒子、屏幕震动与能量条反馈,Phaser 对 2D 渲染、时间循环、sprite animation、camera、粒子和 Scene 生命周期支持成熟。
|
||||
2. 当前项目主前端已使用 TypeScript + Vite,继续复用现有构建和测试体系,避免引入独立构建链。
|
||||
3. 文字密集、权限提示、结算、设置和移动端响应式布局适合 DOM HUD;Canvas 保持负责 playfield 和动态特效。
|
||||
4. Web Audio API 可以在浏览器端完成 MVP 所需音量采样、RMS/peak 计算、环境噪音校准和输入归一化,不需要首版接入后端音频处理。
|
||||
|
||||
### 2.3 不选择其它路线的原因
|
||||
|
||||
- 不使用 Three.js / 3D:当前玩法画面是 2D 横版舞台,不需要 3D 相机、模型和材质管线。
|
||||
- 不把 HUD 全部塞入 Phaser Canvas:权限说明、重试、结算、移动端布局和可访问性更适合 DOM。
|
||||
- 不在前端实现正式业务真相:浏览器 runtime 可承载单局即时 simulation,但若后续涉及成绩、作品、排行榜、发布和奖励,必须交给后端投影/API 裁决。
|
||||
|
||||
## 3. 总体架构
|
||||
|
||||
### 3.1 分层总览
|
||||
|
||||
```text
|
||||
React Runtime Shell
|
||||
├─ DOM HUD / Panels
|
||||
│ ├─ PermissionPanel
|
||||
│ ├─ TopEnergyBar
|
||||
│ ├─ TimerChip
|
||||
│ └─ ResultPanel
|
||||
│
|
||||
├─ Application Controller
|
||||
│ ├─ permission / calibration orchestration
|
||||
│ ├─ simulation tick
|
||||
│ ├─ audio sample submission
|
||||
│ └─ snapshot publish
|
||||
│
|
||||
├─ Pure Domain / Simulation
|
||||
│ ├─ BarkBattleSession
|
||||
│ ├─ BarkDetector
|
||||
│ ├─ EnergyTugOfWar
|
||||
│ ├─ BarkBattleScoring
|
||||
│ └─ OpponentStrategy
|
||||
│
|
||||
├─ Infrastructure Adapters
|
||||
│ ├─ BrowserMicrophoneInput
|
||||
│ ├─ AudioAnalyserSampler
|
||||
│ └─ PhaserGameHost
|
||||
│
|
||||
└─ Phaser Renderer
|
||||
├─ BootScene
|
||||
├─ PreloadScene
|
||||
├─ BattleScene
|
||||
├─ FxScene / DebugScene(可选)
|
||||
└─ Asset manifest
|
||||
```
|
||||
|
||||
### 3.2 强制边界
|
||||
|
||||
1. `domain/` 不依赖 Phaser、Web Audio、DOM、React、浏览器全局对象或后端 API。
|
||||
2. `domain/` 只接收 plain data,例如时间增量、归一化音量样本、对手 power、配置参数。
|
||||
3. `application/` 负责编排权限、校准、音频输入、AI 对手、tick 和 snapshot 分发。
|
||||
4. Phaser Scene 只消费 `BarkBattleSnapshot`,把 snapshot 映射成 sprite、动画、粒子、camera 和 sound effect;不得持有核心胜负、计数和能量条规则。
|
||||
5. DOM HUD 只消费 snapshot 和少量 runtime UI 状态,负责展示、按钮和弹层;不得重复实现核心胜负规则。
|
||||
6. 若后续接入平台作品/成绩/排行榜,前端只调用后端 API 和展示投影,不在本地绕过后端生成正式结论。
|
||||
|
||||
## 4. 建议目录结构
|
||||
|
||||
首版建议以独立 runtime 原型落在 `src/games/bark-battle/`,避免提前侵入平台创作链路。
|
||||
|
||||
```text
|
||||
src/games/bark-battle/
|
||||
domain/
|
||||
BarkBattleTypes.ts
|
||||
BarkBattleSession.ts
|
||||
BarkDetector.ts
|
||||
EnergyTugOfWar.ts
|
||||
BarkBattleScoring.ts
|
||||
OpponentStrategy.ts
|
||||
__tests__/
|
||||
BarkDetector.test.ts
|
||||
EnergyTugOfWar.test.ts
|
||||
BarkBattleSession.test.ts
|
||||
BarkBattleScoring.test.ts
|
||||
|
||||
application/
|
||||
BarkBattleController.ts
|
||||
BarkBattleConfig.ts
|
||||
BarkBattleSnapshotStore.ts
|
||||
__tests__/
|
||||
BarkBattleController.test.ts
|
||||
|
||||
infrastructure/
|
||||
BrowserMicrophoneInput.ts
|
||||
AudioAnalyserSampler.ts
|
||||
MicrophonePermission.ts
|
||||
__tests__/
|
||||
BrowserMicrophoneInput.test.ts
|
||||
AudioAnalyserSampler.test.ts
|
||||
|
||||
phaser/
|
||||
BarkBattleGameHost.ts
|
||||
scenes/
|
||||
BarkBattleBootScene.ts
|
||||
BarkBattlePreloadScene.ts
|
||||
BarkBattleScene.ts
|
||||
BarkBattleFxScene.ts
|
||||
assets/
|
||||
barkBattleAssetManifest.ts
|
||||
|
||||
ui/
|
||||
BarkBattleRuntimeShell.tsx
|
||||
BarkBattleHud.tsx
|
||||
BarkBattlePermissionPanel.tsx
|
||||
BarkBattleResultPanel.tsx
|
||||
BarkBattleMobileControls.tsx
|
||||
BarkBattleHud.css
|
||||
__tests__/
|
||||
BarkBattleHud.test.tsx
|
||||
BarkBattlePermissionPanel.test.tsx
|
||||
BarkBattleResultPanel.test.tsx
|
||||
```
|
||||
|
||||
若后续进入 Genarrative 正式玩法类型闭环,再按 `genarrative-play-type-integration` 扩展到:
|
||||
|
||||
```text
|
||||
src/components/bark-battle-runtime/BarkBattleRuntimeShell.tsx
|
||||
src/components/bark-battle-result/BarkBattleResultView.tsx
|
||||
src/services/barkBattleRuntimeClient.ts
|
||||
packages/shared/src/contracts/barkBattle.ts
|
||||
server-rs/crates/shared-contracts/src/bark_battle.rs
|
||||
```
|
||||
|
||||
首版不建议直接新增后端表或正式作品链路,除非产品明确要求成绩、发布和广场能力。
|
||||
|
||||
## 5. 核心 Domain 类型
|
||||
|
||||
### 5.1 基础枚举与数值约定
|
||||
|
||||
```ts
|
||||
export type BarkBattlePhase =
|
||||
| 'permission'
|
||||
| 'calibration'
|
||||
| 'countdown'
|
||||
| 'playing'
|
||||
| 'finished'
|
||||
| 'unavailable'
|
||||
|
||||
export type BarkBattleSide = 'player' | 'opponent'
|
||||
|
||||
export type BarkBattleWinner = BarkBattleSide | 'draw' | null
|
||||
|
||||
export type BarkBattleDifficulty = 'easy' | 'normal' | 'hard'
|
||||
|
||||
export type BarkBattleUiState =
|
||||
| 'idle'
|
||||
| 'permission-ready'
|
||||
| 'microphone-authorized'
|
||||
| 'calibrating'
|
||||
| 'ready-countdown'
|
||||
| 'playing'
|
||||
| 'finished'
|
||||
| 'microphone-unavailable'
|
||||
|
||||
export type MicrophoneFailureReason =
|
||||
| 'unsupported'
|
||||
| 'permission-denied'
|
||||
| 'non-secure-context'
|
||||
| 'not-found'
|
||||
| 'not-readable'
|
||||
| 'audio-context-blocked'
|
||||
| 'calibration-timeout'
|
||||
| 'calibration-sample-unreadable'
|
||||
| 'unknown'
|
||||
```
|
||||
|
||||
关键数值:
|
||||
|
||||
- `energy`: `-100..100`,正数偏玩家侧,负数偏对手侧。
|
||||
- `currentVolume`: `0..1`,音频采样归一化后的瞬时音量。
|
||||
- `recentPeak`: `0..1`,短窗口内峰值。
|
||||
- `power`: `0..1` 或 `0..100` 二选一,建议 domain 内统一 `0..1`,HUD 显示再转百分比。
|
||||
- `remainingMs`: 单局剩余毫秒。
|
||||
|
||||
### 5.2 Snapshot
|
||||
|
||||
```ts
|
||||
export type BarkBattleSnapshot = {
|
||||
phase: BarkBattlePhase
|
||||
uiState: BarkBattleUiState
|
||||
errorReason: MicrophoneFailureReason | null
|
||||
statusMessageKey: BarkBattleStatusMessageKey | null
|
||||
elapsedMs: number
|
||||
remainingMs: number
|
||||
countdownMs: number
|
||||
energy: number
|
||||
player: BarkSideState
|
||||
opponent: BarkSideState
|
||||
winner: BarkBattleWinner
|
||||
result: BarkBattleResult | null
|
||||
lastEvents: BarkBattleVisualEvent[]
|
||||
}
|
||||
|
||||
export type BarkSideState = {
|
||||
side: BarkBattleSide
|
||||
barkCount: number
|
||||
currentVolume: number
|
||||
recentPeak: number
|
||||
combo: number
|
||||
power: number
|
||||
isBarking: boolean
|
||||
lastBarkAtMs: number | null
|
||||
maxVolume: number
|
||||
}
|
||||
|
||||
export type BarkBattleResult = {
|
||||
winner: BarkBattleWinner
|
||||
finalEnergy: number
|
||||
playerBarkCount: number
|
||||
playerMaxVolume: number
|
||||
playerAveragePower: number
|
||||
score: number
|
||||
}
|
||||
|
||||
export type BarkBattleStatusMessageKey =
|
||||
| 'microphone-unsupported'
|
||||
| 'microphone-permission-denied'
|
||||
| 'microphone-non-secure-context'
|
||||
| 'microphone-not-found'
|
||||
| 'microphone-not-readable'
|
||||
| 'microphone-audio-context-blocked'
|
||||
| 'microphone-calibration-timeout'
|
||||
| 'microphone-calibration-sample-unreadable'
|
||||
| 'microphone-unknown-error'
|
||||
```
|
||||
|
||||
### 5.3 输入样本与叫声事件
|
||||
|
||||
```ts
|
||||
export type BarkAudioSample = {
|
||||
atMs: number
|
||||
volume: number
|
||||
peak: number
|
||||
rms: number
|
||||
}
|
||||
|
||||
export type BarkDetectedEvent = {
|
||||
id: string
|
||||
atMs: number
|
||||
side: BarkBattleSide
|
||||
volume: number
|
||||
strength: number
|
||||
combo: number
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 视觉事件
|
||||
|
||||
视觉事件由 domain 或 application 生成,但只包含 plain data,不包含 Phaser 对象:
|
||||
|
||||
```ts
|
||||
export type BarkBattleVisualEvent =
|
||||
| {
|
||||
type: 'bark-word'
|
||||
id: string
|
||||
side: BarkBattleSide
|
||||
atMs: number
|
||||
strength: number
|
||||
text: 'BARK' | 'WOOF' | 'WAN' | 'WANGOOF'
|
||||
}
|
||||
| {
|
||||
type: 'shockwave'
|
||||
id: string
|
||||
side: BarkBattleSide
|
||||
atMs: number
|
||||
strength: number
|
||||
}
|
||||
| {
|
||||
type: 'combo-burst'
|
||||
id: string
|
||||
side: BarkBattleSide
|
||||
atMs: number
|
||||
combo: number
|
||||
}
|
||||
```
|
||||
|
||||
Phaser 只根据这些事件播放一次性特效,并维护已消费事件 ID,避免重复播放。
|
||||
|
||||
## 6. Domain 模块职责
|
||||
|
||||
### 6.1 BarkDetector
|
||||
|
||||
职责:把连续音频样本转换为有效叫声事件。
|
||||
|
||||
输入:
|
||||
|
||||
- `BarkAudioSample`
|
||||
- 校准后的 `ambientNoiseFloor`
|
||||
- `barkThreshold`
|
||||
- `minBarkGapMs`
|
||||
- `minBarkDurationMs`
|
||||
- `maxBarkDurationMs`
|
||||
|
||||
规则建议:
|
||||
|
||||
1. 音量超过动态阈值进入 candidate 状态。
|
||||
2. 峰值回落到阈值以下或持续时长达到上限时结束 candidate。
|
||||
3. candidate 持续时间在 `80ms..1200ms` 且与上一次有效叫声间隔足够时,记为一次叫声。
|
||||
4. 长时间持续噪音不应无限计数,只能按冷却和峰值回落形成新事件。
|
||||
5. MVP 不要求识别“是否真狗叫”,先基于音量峰值、时长和间隔判断。
|
||||
|
||||
### 6.2 EnergyTugOfWar
|
||||
|
||||
职责:更新红蓝拉锯条。
|
||||
|
||||
建议公式:
|
||||
|
||||
```text
|
||||
playerPower = volumeScore * 0.65 + barkRateScore * 0.35 + comboBonus
|
||||
opponentPower = opponentStrategy.tick(...)
|
||||
energyDelta = (playerPower - opponentPower) * deltaSeconds * balanceFactor
|
||||
energy = clamp(energy + energyDelta, -100, 100)
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- `EnergyTugOfWar` 不知道玩家来自麦克风还是 mock input。
|
||||
- `EnergyTugOfWar` 不知道 Phaser 能量条宽度。
|
||||
- 平衡参数集中在 `BarkBattleConfig`,不要散落在 Scene 或 HUD 中。
|
||||
|
||||
### 6.3 BarkBattleSession
|
||||
|
||||
职责:管理局内 phase、计时、胜负和 snapshot。
|
||||
|
||||
状态机建议:
|
||||
|
||||
```text
|
||||
permission → calibration → countdown → playing → finished
|
||||
↘ unavailable
|
||||
```
|
||||
|
||||
`phase` 只表达 runtime 是否可继续参与局内流程;所有麦克风不可用、权限失败、非安全上下文和校准失败都统一收敛到 `phase: 'unavailable'`,再通过 `uiState: 'microphone-unavailable'` 与 `errorReason` 区分 HUD 展示和重试策略,避免把基础设施错误枚举直接扩散成 domain 阶段。
|
||||
|
||||
关键规则:
|
||||
|
||||
- `countdown` 结束才进入 `playing`。
|
||||
- `playing` 时 `remainingMs` 随 tick 递减。
|
||||
- `remainingMs <= 0` 后进入 `finished`。
|
||||
- `energy > drawThreshold` 判定玩家胜利。
|
||||
- `energy < -drawThreshold` 判定对手胜利。
|
||||
- `abs(energy) <= drawThreshold` 判定平局。
|
||||
|
||||
### 6.4 OpponentStrategy
|
||||
|
||||
职责:为单机 MVP 提供对手推动力。
|
||||
|
||||
```ts
|
||||
export interface OpponentStrategy {
|
||||
tick(input: OpponentTickInput): OpponentTickOutput
|
||||
}
|
||||
```
|
||||
|
||||
普通难度建议:
|
||||
|
||||
- 周期性小叫声提供基础压力。
|
||||
- 每 3~6 秒一次短爆发。
|
||||
- 玩家大幅领先时可轻微增强,但不能追到不可赢。
|
||||
|
||||
## 7. Web Audio 输入适配
|
||||
|
||||
### 7.1 BrowserMicrophoneInput
|
||||
|
||||
职责:封装浏览器麦克风权限与音频流生命周期。
|
||||
|
||||
建议 API:
|
||||
|
||||
```ts
|
||||
export interface MicrophoneInputPort {
|
||||
isSupported(): boolean
|
||||
requestPermission(): Promise<MicrophoneSession>
|
||||
stop(): void
|
||||
}
|
||||
|
||||
export interface MicrophoneSession {
|
||||
sample(atMs: number): BarkAudioSample
|
||||
stop(): void
|
||||
}
|
||||
```
|
||||
|
||||
实现要点:
|
||||
|
||||
1. 使用 `navigator.mediaDevices?.getUserMedia({ audio: true })`。
|
||||
2. 在用户点击“开始”后创建或 resume `AudioContext`,避免移动端自动播放策略拦截。
|
||||
3. 使用 `AnalyserNode` 读取时域数据,计算 RMS 与 peak。
|
||||
4. 输出归一化样本,不把 `MediaStream`、`AudioContext`、`AnalyserNode` 泄漏到 domain。
|
||||
5. 退出、重开、页面卸载时停止 track,避免麦克风占用残留。
|
||||
|
||||
### 7.2 校准流程
|
||||
|
||||
`calibration` 阶段建议持续 `800ms..1500ms`:
|
||||
|
||||
1. 收集静默环境样本。
|
||||
2. 计算 `ambientNoiseFloor`,例如 `p75` 或均值 + 标准差。
|
||||
3. 设置动态阈值:
|
||||
|
||||
```text
|
||||
barkThreshold = clamp(ambientNoiseFloor + 0.12, 0.18, 0.55)
|
||||
```
|
||||
|
||||
4. 若环境噪音过高,HUD 给出简短提示和“继续 / 重新校准”入口,但不要把长说明常驻在画面上。
|
||||
|
||||
### 7.3 权限与错误分类
|
||||
|
||||
```ts
|
||||
export type MicrophoneFailureReason =
|
||||
| 'unsupported'
|
||||
| 'permission-denied'
|
||||
| 'non-secure-context'
|
||||
| 'not-found'
|
||||
| 'not-readable'
|
||||
| 'audio-context-blocked'
|
||||
| 'calibration-timeout'
|
||||
| 'calibration-sample-unreadable'
|
||||
| 'unknown'
|
||||
```
|
||||
|
||||
错误来源与分层归属:
|
||||
|
||||
| 失败原因 | 主要检测位置 | controller snapshot 表达 | HUD 可区分状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 浏览器无 `mediaDevices.getUserMedia` | `BrowserMicrophoneInput.isSupported()` | `phase: 'unavailable'`, `uiState: 'microphone-unavailable'`, `errorReason: 'unsupported'` | 设备或浏览器不支持麦克风输入,只提供返回入口,不展示可开始声控按钮 |
|
||||
| 非安全上下文 | `BrowserMicrophoneInput.isSupported()` 或 `MicrophonePermission` 预检 `window.isSecureContext` | `phase: 'unavailable'`, `errorReason: 'non-secure-context'` | 当前环境无法使用麦克风,提示使用受支持的安全环境或返回 |
|
||||
| 用户拒绝授权 | `BrowserMicrophoneInput.requestPermission()` 捕获 `NotAllowedError` / `SecurityError` | `phase: 'unavailable'`, `errorReason: 'permission-denied'` | 提供重新授权或返回入口,不进入 calibration/countdown/playing |
|
||||
| 未检测到设备 | `getUserMedia` 捕获 `NotFoundError` / `DevicesNotFoundError` | `phase: 'unavailable'`, `errorReason: 'not-found'` | 展示麦克风不可用,可重试授权或返回 |
|
||||
| 设备被占用或不可读 | `getUserMedia` 捕获 `NotReadableError` / `TrackStartError` | `phase: 'unavailable'`, `errorReason: 'not-readable'` | 展示麦克风不可用,可重试授权或返回 |
|
||||
| AudioContext 被移动端策略拦截 | 用户手势后创建 / resume `AudioContext` 失败 | `phase: 'unavailable'`, `errorReason: 'audio-context-blocked'` | 提示点击重试,不自动循环请求 |
|
||||
| 校准超时 | `BarkBattleController` 在 calibration 阶段等待样本超出 `calibrationMaxWaitMs` | `phase: 'unavailable'`, `errorReason: 'calibration-timeout'` | 展示麦克风输入不可用,提供重试校准入口 |
|
||||
| 校准样本不可读 | `AudioAnalyserSampler.sample()` 持续返回空样本、NaN 或无法读取 buffer | `phase: 'unavailable'`, `errorReason: 'calibration-sample-unreadable'` | 展示麦克风输入不可用,提供重试校准入口 |
|
||||
|
||||
前端只根据错误分类展示可操作状态:重试授权、重试校准、返回、或使用调试备用输入。不要把浏览器原始错误堆栈展示给玩家。
|
||||
|
||||
## 8. Phaser Scene 切分
|
||||
|
||||
### 8.1 BarkBattleBootScene
|
||||
|
||||
职责:
|
||||
|
||||
- 初始化 Phaser 全局配置。
|
||||
- 注册 scale、background color、全局事件桥。
|
||||
- 不加载重资源,不处理玩法规则。
|
||||
|
||||
### 8.2 BarkBattlePreloadScene
|
||||
|
||||
职责:
|
||||
|
||||
- 根据 `barkBattleAssetManifest` 加载背景、狗狗 sprite、声浪 FX、拟声词 bitmap / atlas、轻量音效。
|
||||
- 使用稳定 manifest key,不在 gameplay 代码中散写文件路径。
|
||||
- 加载完成后进入 `BarkBattleScene`。
|
||||
|
||||
### 8.3 BarkBattleScene
|
||||
|
||||
职责:
|
||||
|
||||
- 创建横版舞台、左右狗狗、背景层、声浪层、拟声词层。
|
||||
- 每帧读取最新 `BarkBattleSnapshot`。
|
||||
- 根据 snapshot 更新:
|
||||
- 狗狗 idle / bark / win / lose 动画
|
||||
- 声浪强度
|
||||
- camera shake
|
||||
- transient bark words
|
||||
- shockwave
|
||||
- 把可选的调试输入 action 传给 controller,但不处理麦克风和规则。
|
||||
|
||||
不得在 Scene 中实现:
|
||||
|
||||
- 叫声计数
|
||||
- 胜负判定
|
||||
- 能量条规则
|
||||
- 权限流程
|
||||
- 结算数据计算
|
||||
|
||||
### 8.4 BarkBattleFxScene(可选)
|
||||
|
||||
如果特效复杂,可拆出叠加 Scene:
|
||||
|
||||
- 专门处理拟声词、粒子、冲击波和 camera shake。
|
||||
- 通过视觉事件 ID 去重。
|
||||
- 对 `prefers-reduced-motion` 或低端设备降级。
|
||||
|
||||
首版也可以先把 FX 保持在 `BarkBattleScene` 内,但必须仍然只消费 snapshot / visual events。
|
||||
|
||||
## 9. DOM HUD 设计
|
||||
|
||||
### 9.1 HUD 层级
|
||||
|
||||
DOM HUD 建议覆盖在 Phaser Canvas 上方:
|
||||
|
||||
```text
|
||||
BarkBattleRuntimeShell
|
||||
├─ <div className="bark-battle-canvas-host" />
|
||||
└─ <BarkBattleHud snapshot={snapshot} uiState={uiState} />
|
||||
```
|
||||
|
||||
HUD 分区:
|
||||
|
||||
- 顶部:红蓝声浪能量条 + 小型剩余时间。
|
||||
- 中央:仅在倒计时、关键提示或结算时短暂展示大号状态。
|
||||
- 左右边缘:双方简洁状态,例如叫声次数 / combo chip。
|
||||
- 底部角落:麦克风状态、重试、小菜单。
|
||||
- 结算:独立居中面板,显示胜负、叫声次数、最大音量、评分、再来一局、返回。
|
||||
|
||||
### 9.2 Playfield 保护
|
||||
|
||||
遵循 game UI 约束:
|
||||
|
||||
1. 正常 playing 阶段保持中心和下中部 playfield 清爽,不常驻长文案。
|
||||
2. 不把规则说明、长控制说明、多段提示默认铺在画面上。
|
||||
3. 权限、设置、结算使用独立面板或弹层,不在当前面板下面展开一大块内容。
|
||||
4. 移动端优先保证顶部能量条、倒计时、狗狗和重试入口可见可点。
|
||||
5. 大动效不能遮挡顶部能量条和倒计时。
|
||||
|
||||
### 9.3 CSS 设计建议
|
||||
|
||||
- 使用局部 CSS class 或 CSS module,避免污染全站。
|
||||
- 使用 CSS 变量定义主题:
|
||||
- `--bark-player-color`
|
||||
- `--bark-opponent-color`
|
||||
- `--bark-panel-bg`
|
||||
- `--bark-safe-bottom`
|
||||
- 使用 `dvh` / `svh` 和 safe-area inset 处理移动端地址栏与刘海。
|
||||
- `pointer-events` 分层:HUD 容器默认 `pointer-events: none`,按钮和面板恢复 `pointer-events: auto`。
|
||||
|
||||
## 10. 移动端与权限降级
|
||||
|
||||
### 10.1 移动端输入约束
|
||||
|
||||
移动端浏览器通常要求用户手势才能启动 AudioContext。开局流程必须是:
|
||||
|
||||
```text
|
||||
玩家点击“开始” → requestPermission → 创建/恢复 AudioContext → calibration → countdown → playing
|
||||
```
|
||||
|
||||
不要在页面加载时自动请求或自动启动 AudioContext。
|
||||
|
||||
### 10.2 响应式布局
|
||||
|
||||
移动端建议:
|
||||
|
||||
- 横屏优先呈现完整舞台;竖屏可保持舞台居中并缩小 HUD。
|
||||
- 顶部能量条高度保持可读,但不要占满大面积。
|
||||
- 结算面板宽度使用 `min(92vw, 420px)`。
|
||||
- 底部按钮避开 `env(safe-area-inset-bottom)`。
|
||||
- 非关键设置折叠进小菜单。
|
||||
|
||||
### 10.3 权限失败降级
|
||||
|
||||
权限失败时:
|
||||
|
||||
- `unsupported`:展示“当前浏览器不支持麦克风输入”,提供返回入口,不展示开始声控按钮。
|
||||
- `non-secure-context`:展示“当前环境无法使用麦克风”,提示切换到受支持的安全环境或返回。
|
||||
- `permission-denied`:展示简短说明和“重新授权”入口。
|
||||
- `not-found`:提示未检测到麦克风,提供重试授权或返回入口。
|
||||
- `not-readable`:提示麦克风被占用或暂时不可读,提供重试授权或返回入口。
|
||||
- `audio-context-blocked`:提示点击重试。
|
||||
- `calibration-timeout` / `calibration-sample-unreadable`:提示麦克风输入不可用,提供“重新校准”和返回入口。
|
||||
|
||||
可选开发调试降级:
|
||||
|
||||
- 本地 dev 可启用键盘 mock input,例如按住空格模拟音量峰值。
|
||||
- mock input 必须标记为开发/调试能力,不作为正式竞技能力。
|
||||
|
||||
## 11. 测试策略
|
||||
|
||||
### 11.1 Domain 单元测试(优先)
|
||||
|
||||
目标:不接 Phaser、不接 DOM、不接 Web Audio。
|
||||
|
||||
建议测试:
|
||||
|
||||
- `BarkDetector`:超过阈值且间隔足够时计为一次有效叫声。
|
||||
- `BarkDetector`:持续噪音不会无限计数。
|
||||
- `BarkDetector`:低于环境噪音阈值不计入叫声。
|
||||
- `EnergyTugOfWar`:玩家 power 高于对手时 energy 向玩家侧移动。
|
||||
- `EnergyTugOfWar`:energy clamp 在 `-100..100`。
|
||||
- `BarkBattleSession`:倒计时结束进入 playing。
|
||||
- `BarkBattleSession`:剩余时间归零进入 finished。
|
||||
- `BarkBattleSession`:按 energy 和 drawThreshold 判定胜 / 负 / 平。
|
||||
|
||||
### 11.2 Application 测试
|
||||
|
||||
目标:验证输入样本、AI、tick 和 snapshot 编排。
|
||||
|
||||
建议测试:
|
||||
|
||||
- 权限允许后进入校准,再进入倒计时。
|
||||
- 权限拒绝后 `phase` 为 `unavailable`、`errorReason` 为 `permission-denied`,不进入 playing。
|
||||
- 非安全上下文、设备未找到、设备不可读、AudioContext 被拦截时,controller snapshot 都进入 `phase: 'unavailable'`,并保留可供 HUD 区分的 `errorReason`。
|
||||
- 校准超时或样本持续不可读时,controller snapshot 使用 `errorReason: 'calibration-timeout'` 或 `calibration-sample-unreadable`,并提供重试校准动作。
|
||||
- 提交 mock audio sample 后 snapshot 中玩家状态更新。
|
||||
- AI 对手 power 参与能量条拉锯。
|
||||
- `lastEvents` 只发布新增视觉事件。
|
||||
|
||||
### 11.3 HUD 组件测试
|
||||
|
||||
目标:验证 snapshot 到 DOM 的展示映射。
|
||||
|
||||
建议测试:
|
||||
|
||||
- playing 阶段展示倒计时和能量条。
|
||||
- energy 正负值映射到玩家 / 对手侧比例。
|
||||
- `errorReason: 'permission-denied'` 展示重试授权入口。
|
||||
- `errorReason: 'unsupported'` 展示返回入口且不展示开始声控按钮。
|
||||
- `not-found`、`not-readable`、`non-secure-context`、`audio-context-blocked`、`calibration-timeout`、`calibration-sample-unreadable` 分别映射到可区分的简短状态文案和对应操作。
|
||||
- finished 展示胜负、叫声次数和再来一局。
|
||||
- 移动端 class / 结构不依赖 Phaser Canvas 才能渲染。
|
||||
|
||||
### 11.4 Phaser 集成与 smoke
|
||||
|
||||
自动化层面不强行在 Vitest 中完整启动 Phaser。建议:
|
||||
|
||||
- 用 adapter mock 测试 Scene 消费 snapshot 的纯映射函数。
|
||||
- 浏览器 smoke 验证真实 Canvas、Web Audio 和动画。
|
||||
- 若后续引入 Playwright,再做最小视觉和交互 smoke。
|
||||
|
||||
## 12. 验证命令建议
|
||||
|
||||
文档阶段只需要编码检查和 diff 检查:
|
||||
|
||||
```bash
|
||||
npm run check:encoding
|
||||
git diff -- docs/prd/BARK_BATTLE_BDD_2026-05-11.md docs/technical/BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md
|
||||
```
|
||||
|
||||
后续实现 domain 后建议:
|
||||
|
||||
```bash
|
||||
npm run test -- --run src/games/bark-battle/domain/**/*.test.ts
|
||||
npm run typecheck
|
||||
npm run check:encoding
|
||||
```
|
||||
|
||||
后续实现 infrastructure/application 错误状态后建议:
|
||||
|
||||
```bash
|
||||
npm run test -- --run src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts src/games/bark-battle/application/__tests__/BarkBattleController.test.ts
|
||||
npm run typecheck
|
||||
npm run check:encoding
|
||||
```
|
||||
|
||||
后续实现 HUD 后建议:
|
||||
|
||||
```bash
|
||||
npm run test -- --run src/games/bark-battle/ui/**/*.test.tsx
|
||||
npm run lint:eslint
|
||||
npm run typecheck
|
||||
npm run check:encoding
|
||||
```
|
||||
|
||||
后续接入浏览器 runtime 后建议:
|
||||
|
||||
```bash
|
||||
npm run dev:web
|
||||
# 人工 smoke:授权麦克风 → 校准 → 发声 → 能量条变化 → 结算 → 再来一局
|
||||
```
|
||||
|
||||
若未来接入 Genarrative 正式玩法类型、后端持久化或发布链路,再追加对应契约、api-server 和 SpacetimeDB 验证;首版 runtime 原型不应提前新增这些命令作为门槛。
|
||||
|
||||
## 13. 后续落地顺序
|
||||
|
||||
建议后续实现按以下顺序推进:
|
||||
|
||||
1. 先建 `domain/` 和纯单元测试。
|
||||
2. 实现 `BarkDetector`、`EnergyTugOfWar`、`BarkBattleSession` 的最小规则。
|
||||
3. 建 `application/` controller,用 mock audio sample 跑通 snapshot。
|
||||
4. 实现 DOM HUD 的 permission / energy / timer / result 展示。
|
||||
5. 接入 `BrowserMicrophoneInput` 和校准流程。
|
||||
6. 接入 Phaser host、Scene 和 asset manifest,占位素材先跑通视觉反馈。
|
||||
7. 做移动端视口和权限失败 smoke。
|
||||
8. 产品确认后再决定是否进入正式玩法类型、作品发布和后端真相链。
|
||||
|
||||
## 14. 关键技术决策
|
||||
|
||||
1. 默认采用 Phaser 3 + TypeScript + Vite,符合 2D 浏览器游戏默认路线。
|
||||
2. 核心 simulation 放在纯 TypeScript domain,严格不依赖 Phaser / Web Audio / DOM。
|
||||
3. Web Audio 只作为输入 adapter,输出归一化 `BarkAudioSample`。
|
||||
4. Phaser Scene 是 renderer,只消费 snapshot 和 visual events,不承载规则真相。
|
||||
5. HUD 使用 DOM overlay,承载权限、能量条、倒计时、结算和移动端响应式布局。
|
||||
6. MVP 不做复杂狗叫语义识别,先用音量峰值、持续时长、冷却和环境噪音校准。
|
||||
7. MVP 建议玩家 vs AI 单机 runtime,正式成绩、排行榜、发布和奖励后续再交给后端链路。
|
||||
1055
docs/technical/BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md
Normal 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 等设备统一归一为通用拖拽语义,玩法组件只负责解释目标和落点。
|
||||
|
||||
17
public/bark-battle-assets/bark-battle-image-prompts.json
Normal file
@@ -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,不要只生成“王”字"
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
BIN
public/bark-battle-assets/generated/bark-battle-player-back.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
145
scripts/generate-bark-battle-assets.mjs
Normal 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));
|
||||
@@ -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
src/BarkBattlePlaygroundApp.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { BarkBattleRuntimeShell } from './games/bark-battle/ui/BarkBattleRuntimeShell';
|
||||
|
||||
export default function BarkBattlePlaygroundApp() {
|
||||
return <BarkBattleRuntimeShell />;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -79,6 +79,12 @@ export type CreationWorkShelfSource =
|
||||
item: VisualNovelWorkSummary;
|
||||
};
|
||||
|
||||
export type CreationWorkShelfActions = {
|
||||
open: () => void;
|
||||
delete?: () => void;
|
||||
claimPointIncentive?: () => void;
|
||||
};
|
||||
|
||||
export type CreationWorkShelfItem = {
|
||||
id: string;
|
||||
kind: CreationWorkShelfKind;
|
||||
@@ -99,6 +105,7 @@ export type CreationWorkShelfItem = {
|
||||
badges: CreationWorkShelfBadge[];
|
||||
metrics: CreationWorkShelfMetric[];
|
||||
pointIncentive?: CreationWorkShelfPointIncentive;
|
||||
actions: CreationWorkShelfActions;
|
||||
source: CreationWorkShelfSource;
|
||||
};
|
||||
|
||||
@@ -116,6 +123,20 @@ export function buildCreationWorkShelfItems(params: {
|
||||
canDeleteSquareHole?: boolean;
|
||||
canDeletePuzzle?: boolean;
|
||||
canDeleteVisualNovel?: boolean;
|
||||
onOpenRpgDraft?: (item: CustomWorldWorkSummary) => void;
|
||||
onEnterRpgPublished?: (profileId: string) => void;
|
||||
onDeleteRpg?: (item: CustomWorldWorkSummary) => void;
|
||||
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
|
||||
onDeleteBigFish?: (item: BigFishWorkSummary) => void;
|
||||
onOpenMatch3DDetail?: (item: Match3DWorkSummary) => void;
|
||||
onDeleteMatch3D?: (item: Match3DWorkSummary) => void;
|
||||
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
|
||||
onDeleteSquareHole?: (item: SquareHoleWorkSummary) => void;
|
||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
|
||||
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
|
||||
onOpenVisualNovelDetail?: (item: VisualNovelWorkSummary) => void;
|
||||
onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void;
|
||||
getItemState?: (
|
||||
item: CreationWorkShelfItem,
|
||||
) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null;
|
||||
@@ -134,27 +155,61 @@ export function buildCreationWorkShelfItems(params: {
|
||||
canDeleteSquareHole = false,
|
||||
canDeletePuzzle = false,
|
||||
canDeleteVisualNovel = false,
|
||||
onOpenRpgDraft,
|
||||
onEnterRpgPublished,
|
||||
onDeleteRpg,
|
||||
onOpenBigFishDetail,
|
||||
onDeleteBigFish,
|
||||
onOpenMatch3DDetail,
|
||||
onDeleteMatch3D,
|
||||
onOpenSquareHoleDetail,
|
||||
onDeleteSquareHole,
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle,
|
||||
onClaimPuzzlePointIncentive,
|
||||
onOpenVisualNovelDetail,
|
||||
onDeleteVisualNovel,
|
||||
getItemState,
|
||||
} = params;
|
||||
|
||||
return [
|
||||
...rpgItems.map((item) =>
|
||||
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries),
|
||||
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries, {
|
||||
onOpenDraft: onOpenRpgDraft,
|
||||
onEnterPublished: onEnterRpgPublished,
|
||||
onDelete: onDeleteRpg,
|
||||
}),
|
||||
),
|
||||
...bigFishItems.map((item) =>
|
||||
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
|
||||
mapBigFishWorkToShelfItem(item, canDeleteBigFish, {
|
||||
onOpen: onOpenBigFishDetail,
|
||||
onDelete: onDeleteBigFish,
|
||||
}),
|
||||
),
|
||||
...match3dItems.map((item) =>
|
||||
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D),
|
||||
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D, {
|
||||
onOpen: onOpenMatch3DDetail,
|
||||
onDelete: onDeleteMatch3D,
|
||||
}),
|
||||
),
|
||||
...squareHoleItems.map((item) =>
|
||||
mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole),
|
||||
mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole, {
|
||||
onOpen: onOpenSquareHoleDetail,
|
||||
onDelete: onDeleteSquareHole,
|
||||
}),
|
||||
),
|
||||
...puzzleItems.map((item) =>
|
||||
mapPuzzleWorkToShelfItem(item, canDeletePuzzle),
|
||||
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
|
||||
onOpen: onOpenPuzzleDetail,
|
||||
onDelete: onDeletePuzzle,
|
||||
onClaimPointIncentive: onClaimPuzzlePointIncentive,
|
||||
}),
|
||||
),
|
||||
...visualNovelItems.map((item) =>
|
||||
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel),
|
||||
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, {
|
||||
onOpen: onOpenVisualNovelDetail,
|
||||
onDelete: onDeleteVisualNovel,
|
||||
}),
|
||||
),
|
||||
]
|
||||
.map((item) => {
|
||||
@@ -173,10 +228,26 @@ export function buildCreationWorkShelfItems(params: {
|
||||
);
|
||||
}
|
||||
|
||||
type RpgWorkShelfAdapter = {
|
||||
onOpenDraft?: (item: CustomWorldWorkSummary) => void;
|
||||
onEnterPublished?: (profileId: string) => void;
|
||||
onDelete?: (item: CustomWorldWorkSummary) => void;
|
||||
};
|
||||
|
||||
type WorkShelfAdapter<TItem> = {
|
||||
onOpen?: (item: TItem) => void;
|
||||
onDelete?: (item: TItem) => void;
|
||||
};
|
||||
|
||||
type PuzzleWorkShelfAdapter = WorkShelfAdapter<PuzzleWorkSummary> & {
|
||||
onClaimPointIncentive?: (item: PuzzleWorkSummary) => void;
|
||||
};
|
||||
|
||||
function mapRpgWorkToShelfItem(
|
||||
item: CustomWorldWorkSummary,
|
||||
canDelete: boolean,
|
||||
libraryEntries: CustomWorldLibraryEntry<CustomWorldProfile>[],
|
||||
adapter: RpgWorkShelfAdapter,
|
||||
): CreationWorkShelfItem {
|
||||
const isDraft = item.status === 'draft';
|
||||
const libraryEntry = item.profileId
|
||||
@@ -217,6 +288,7 @@ function mapRpgWorkToShelfItem(
|
||||
: '查看详情',
|
||||
canDelete,
|
||||
canShare: item.status === 'published' && Boolean(publicWorkCode),
|
||||
actions: buildRpgWorkShelfActions(item, adapter),
|
||||
badges,
|
||||
metrics: isDraft ? [] : metrics,
|
||||
source: { kind: 'rpg', item },
|
||||
@@ -226,6 +298,7 @@ function mapRpgWorkToShelfItem(
|
||||
function mapBigFishWorkToShelfItem(
|
||||
item: BigFishWorkSummary,
|
||||
canDelete: boolean,
|
||||
adapter: WorkShelfAdapter<BigFishWorkSummary>,
|
||||
): CreationWorkShelfItem {
|
||||
const isPublished = item.status === 'published';
|
||||
const publicWorkCode = isPublished
|
||||
@@ -250,6 +323,7 @@ function mapBigFishWorkToShelfItem(
|
||||
openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情',
|
||||
canDelete,
|
||||
canShare: isPublished && Boolean(publicWorkCode),
|
||||
actions: buildWorkShelfActions(item, adapter),
|
||||
badges: [
|
||||
buildStatusBadge(item.status),
|
||||
{ id: 'type', label: '大鱼', tone: 'neutral' },
|
||||
@@ -268,6 +342,7 @@ function mapBigFishWorkToShelfItem(
|
||||
function mapMatch3DWorkToShelfItem(
|
||||
item: Match3DWorkSummary,
|
||||
canDelete: boolean,
|
||||
adapter: WorkShelfAdapter<Match3DWorkSummary>,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
|
||||
const publicWorkCode =
|
||||
@@ -291,6 +366,7 @@ function mapMatch3DWorkToShelfItem(
|
||||
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
|
||||
canDelete,
|
||||
canShare: status === 'published' && Boolean(publicWorkCode),
|
||||
actions: buildWorkShelfActions(item, adapter),
|
||||
badges: [
|
||||
buildStatusBadge(status),
|
||||
{ id: 'type', label: '抓鹅', tone: 'neutral' },
|
||||
@@ -310,6 +386,7 @@ function mapMatch3DWorkToShelfItem(
|
||||
function mapPuzzleWorkToShelfItem(
|
||||
item: PuzzleWorkSummary,
|
||||
canDelete: boolean,
|
||||
adapter: PuzzleWorkShelfAdapter,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus;
|
||||
const publicWorkCode =
|
||||
@@ -337,6 +414,7 @@ function mapPuzzleWorkToShelfItem(
|
||||
status === 'published' && !item.sourceSessionId ? '查看详情' : '继续创作',
|
||||
canDelete,
|
||||
canShare: status === 'published' && Boolean(publicWorkCode),
|
||||
actions: buildPuzzleWorkShelfActions(item, adapter),
|
||||
badges: [
|
||||
buildStatusBadge(status),
|
||||
{ id: 'type', label: '拼图', tone: 'neutral' },
|
||||
@@ -371,6 +449,7 @@ function mapPuzzleWorkToShelfItem(
|
||||
function mapVisualNovelWorkToShelfItem(
|
||||
item: VisualNovelWorkSummary,
|
||||
canDelete: boolean,
|
||||
adapter: WorkShelfAdapter<VisualNovelWorkSummary>,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publishStatus === 'published' ? 'published' : 'draft';
|
||||
const publicWorkCode =
|
||||
@@ -411,6 +490,7 @@ function mapVisualNovelWorkToShelfItem(
|
||||
likeCount: 0,
|
||||
})
|
||||
: [],
|
||||
actions: buildWorkShelfActions(item, adapter),
|
||||
source: { kind: 'visual-novel', item },
|
||||
};
|
||||
}
|
||||
@@ -418,6 +498,7 @@ function mapVisualNovelWorkToShelfItem(
|
||||
function mapSquareHoleWorkToShelfItem(
|
||||
item: SquareHoleWorkSummary,
|
||||
canDelete: boolean,
|
||||
adapter: WorkShelfAdapter<SquareHoleWorkSummary>,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
|
||||
const publicWorkCode =
|
||||
@@ -455,10 +536,65 @@ function mapSquareHoleWorkToShelfItem(
|
||||
likeCount: 0,
|
||||
})
|
||||
: [],
|
||||
actions: buildWorkShelfActions(item, adapter),
|
||||
source: { kind: 'square-hole', item },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function buildWorkShelfActions<TItem>(
|
||||
item: TItem,
|
||||
adapter: WorkShelfAdapter<TItem>,
|
||||
): CreationWorkShelfActions {
|
||||
return {
|
||||
open: () => {
|
||||
adapter.onOpen?.(item);
|
||||
},
|
||||
delete: adapter.onDelete
|
||||
? () => {
|
||||
adapter.onDelete?.(item);
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleWorkShelfActions(
|
||||
item: PuzzleWorkSummary,
|
||||
adapter: PuzzleWorkShelfAdapter,
|
||||
): CreationWorkShelfActions {
|
||||
return {
|
||||
...buildWorkShelfActions(item, adapter),
|
||||
claimPointIncentive: adapter.onClaimPointIncentive
|
||||
? () => {
|
||||
adapter.onClaimPointIncentive?.(item);
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRpgWorkShelfActions(
|
||||
item: CustomWorldWorkSummary,
|
||||
adapter: RpgWorkShelfAdapter,
|
||||
): CreationWorkShelfActions {
|
||||
return {
|
||||
open: () => {
|
||||
if (item.status === 'draft') {
|
||||
adapter.onOpenDraft?.(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.profileId) {
|
||||
adapter.onEnterPublished?.(item.profileId);
|
||||
}
|
||||
},
|
||||
delete: adapter.onDelete
|
||||
? () => {
|
||||
adapter.onDelete?.(item);
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPublishedMetrics(params: {
|
||||
playCount?: number | null;
|
||||
remixCount?: number | null;
|
||||
|
||||
@@ -100,10 +100,7 @@ import {
|
||||
buildPublicWorkStagePath,
|
||||
pushAppHistoryPath,
|
||||
} from '../../routing/appPageRoutes';
|
||||
import {
|
||||
resolveRuntimeNotFoundRecoveryAction,
|
||||
resolveWorkNotFoundRecoveryAction,
|
||||
} from '../../routing/runtimeNotFoundRecovery';
|
||||
import { resolveWorkNotFoundRecoveryAction } from '../../routing/runtimeNotFoundRecovery';
|
||||
import {
|
||||
ApiClientError,
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
@@ -148,13 +145,7 @@ import {
|
||||
shouldRestoreCustomWorldAgentUiState,
|
||||
} from '../../services/customWorldAgentUiState';
|
||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||
import {
|
||||
clickMatch3DItem,
|
||||
finishMatch3DTimeUp,
|
||||
restartMatch3DRun,
|
||||
startMatch3DRun,
|
||||
stopMatch3DRun,
|
||||
} from '../../services/match3d-runtime';
|
||||
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
|
||||
import {
|
||||
deleteMatch3DWork,
|
||||
getMatch3DWorkDetail,
|
||||
@@ -983,24 +974,6 @@ function isMissingPuzzleWorkError(error: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
function maybeAlertRuntimeNotFoundAndReturnHome() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const recoveryAction = resolveRuntimeNotFoundRecoveryAction(
|
||||
window.location.pathname,
|
||||
);
|
||||
if (!recoveryAction) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 中文注释:直接 runtime 深链找不到作品时,弹窗确认后立刻回首页,避免保留空白运行态。
|
||||
window.alert('作品不存在或已下架,将返回首页。');
|
||||
pushAppHistoryPath(recoveryAction.nextPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
function maybeAlertWorkNotFoundAndReturnHome() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
@@ -3302,6 +3275,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
},
|
||||
});
|
||||
|
||||
const match3dRuntimeAdapter = useMemo(
|
||||
() => createServerMatch3DRuntimeAdapter(),
|
||||
[],
|
||||
);
|
||||
const match3dFlow = usePlatformCreationAgentFlowController<
|
||||
Match3DAgentSessionSnapshot,
|
||||
CreateMatch3DSessionRequest,
|
||||
@@ -5342,11 +5319,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
}
|
||||
const { run } = options.embedded
|
||||
? await startMatch3DRun(
|
||||
? await match3dRuntimeAdapter.startRun(
|
||||
runtimeProfile.profileId,
|
||||
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
)
|
||||
: await startMatch3DRun(runtimeProfile.profileId);
|
||||
: await match3dRuntimeAdapter.startRun(runtimeProfile.profileId);
|
||||
setMatch3DRun(run);
|
||||
setMatch3DProfile(runtimeProfile);
|
||||
setMatch3DRuntimeReturnStage(returnStage);
|
||||
@@ -5382,6 +5359,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
[
|
||||
isMatch3DBusy,
|
||||
match3dFlow,
|
||||
match3dRuntimeAdapter,
|
||||
resolveMatch3DErrorMessage,
|
||||
setMatch3DError,
|
||||
setSelectionStage,
|
||||
@@ -7810,7 +7788,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
match3dFlow.setIsBusy(true);
|
||||
setMatch3DError(null);
|
||||
void restartMatch3DRun(match3dRun.runId)
|
||||
void match3dRuntimeAdapter.restartRun(match3dRun.runId)
|
||||
.then(({ run }) => {
|
||||
setMatch3DRun(run);
|
||||
})
|
||||
@@ -7829,14 +7807,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
if (!runId) {
|
||||
return Promise.reject(new Error('抓大鹅运行态缺少 runId。'));
|
||||
}
|
||||
return clickMatch3DItem(runId, payload);
|
||||
return match3dRuntimeAdapter.clickItem(runId, payload);
|
||||
}}
|
||||
onTimeExpired={() => {
|
||||
if (!match3dRun?.runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void finishMatch3DTimeUp(match3dRun.runId)
|
||||
void match3dRuntimeAdapter.finishTimeUp(match3dRun.runId)
|
||||
.then(({ run }) => {
|
||||
setMatch3DRun(run);
|
||||
})
|
||||
@@ -8004,6 +7982,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
match3dFlow,
|
||||
match3dProfile,
|
||||
match3dRun,
|
||||
match3dRuntimeAdapter,
|
||||
platformBootstrap.platformTab,
|
||||
platformThemeClass,
|
||||
puzzleError,
|
||||
@@ -8567,6 +8546,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
squareHoleGalleryEntries,
|
||||
selectionStage,
|
||||
setPlatformTab,
|
||||
setPuzzleError,
|
||||
setSelectionStage,
|
||||
visualNovelGalleryEntries,
|
||||
],
|
||||
);
|
||||
@@ -9764,7 +9745,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
)}
|
||||
onBack={() => {
|
||||
if (match3dRun?.runId && match3dRun.status === 'running') {
|
||||
void stopMatch3DRun(match3dRun.runId).catch(
|
||||
void match3dRuntimeAdapter.stopRun(match3dRun.runId).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
@@ -9777,7 +9758,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
match3dFlow.setIsBusy(true);
|
||||
setMatch3DError(null);
|
||||
void restartMatch3DRun(match3dRun.runId)
|
||||
void match3dRuntimeAdapter.restartRun(match3dRun.runId)
|
||||
.then(({ run }) => {
|
||||
setMatch3DRun(run);
|
||||
})
|
||||
@@ -9801,14 +9782,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
new Error('抓大鹅运行态缺少 runId。'),
|
||||
);
|
||||
}
|
||||
return clickMatch3DItem(runId, payload);
|
||||
return match3dRuntimeAdapter.clickItem(runId, payload);
|
||||
}}
|
||||
onTimeExpired={() => {
|
||||
if (!match3dRun?.runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void finishMatch3DTimeUp(match3dRun.runId)
|
||||
void match3dRuntimeAdapter.finishTimeUp(match3dRun.runId)
|
||||
.then(({ run }) => {
|
||||
setMatch3DRun(run);
|
||||
})
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||
import {
|
||||
clickMatch3DItem,
|
||||
createServerMatch3DRuntimeAdapter,
|
||||
finishMatch3DTimeUp,
|
||||
restartMatch3DRun,
|
||||
startMatch3DRun,
|
||||
@@ -437,14 +438,35 @@ vi.mock('../../services/match3d-works', () => ({
|
||||
updateMatch3DGeneratedItemAssets: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3d-runtime', () => ({
|
||||
const match3dRuntimeServiceMocks = vi.hoisted(() => ({
|
||||
clickMatch3DItem: vi.fn(),
|
||||
createServerMatch3DRuntimeAdapter: vi.fn(),
|
||||
finishMatch3DTimeUp: vi.fn(),
|
||||
restartMatch3DRun: vi.fn(),
|
||||
startMatch3DRun: vi.fn(),
|
||||
stopMatch3DRun: vi.fn(),
|
||||
}));
|
||||
|
||||
const match3dServerRuntimeAdapterMock = vi.hoisted(() => ({
|
||||
clickItem: vi.fn(),
|
||||
finishTimeUp: vi.fn(),
|
||||
getRun: vi.fn(),
|
||||
restartRun: vi.fn(),
|
||||
startRun: vi.fn(),
|
||||
stopRun: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3d-runtime', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/match3d-runtime')
|
||||
>('../../services/match3d-runtime');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
...match3dRuntimeServiceMocks,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/square-hole-creation', () => ({
|
||||
squareHoleCreationClient: {
|
||||
createSession: vi.fn(),
|
||||
@@ -1561,6 +1583,24 @@ function TestWrapper({
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(createServerMatch3DRuntimeAdapter).mockReturnValue(
|
||||
match3dServerRuntimeAdapterMock,
|
||||
);
|
||||
match3dServerRuntimeAdapterMock.startRun.mockRejectedValue(
|
||||
new Error('未启动抓大鹅运行态'),
|
||||
);
|
||||
match3dServerRuntimeAdapterMock.clickItem.mockRejectedValue(
|
||||
new Error('未执行抓大鹅点击'),
|
||||
);
|
||||
match3dServerRuntimeAdapterMock.restartRun.mockRejectedValue(
|
||||
new Error('未重新开始抓大鹅运行态'),
|
||||
);
|
||||
match3dServerRuntimeAdapterMock.finishTimeUp.mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-profile-time-up'),
|
||||
});
|
||||
match3dServerRuntimeAdapterMock.stopRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-profile-stopped'),
|
||||
});
|
||||
window.history.replaceState(null, '', '/');
|
||||
window.sessionStorage.clear();
|
||||
window.localStorage.clear();
|
||||
@@ -3533,7 +3573,7 @@ test('home recommendation Match3D runtime keeps profile generated models when ca
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startMatch3DRun).toHaveBeenCalledWith(
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||
'match3d-profile-card-1',
|
||||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
@@ -4698,7 +4738,7 @@ test('public code search opens a published Match3D work by M3 code and starts ru
|
||||
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
|
||||
item: match3dWork,
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||||
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dWork.profileId),
|
||||
});
|
||||
|
||||
@@ -4715,7 +4755,9 @@ test('public code search opens a published Match3D work by M3 code and starts ru
|
||||
await user.click(screen.getByRole('button', { name: '启动' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-public-1');
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||
'match3d-profile-public-1',
|
||||
);
|
||||
});
|
||||
expect(
|
||||
await screen.findByText(
|
||||
@@ -4769,7 +4811,7 @@ test('published Match3D runtime receives persisted generated models', async () =
|
||||
vi.mocked(listMatch3DGallery).mockResolvedValue({
|
||||
items: [match3dWork],
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||||
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dWork.profileId),
|
||||
});
|
||||
|
||||
@@ -4783,6 +4825,11 @@ test('published Match3D runtime receives persisted generated models', async () =
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||
'match3d-profile-public-1',
|
||||
);
|
||||
});
|
||||
expect(
|
||||
await screen.findByTestId('match3d-runtime-generated-model-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
|
||||
25
src/games/bark-battle/application/BarkBattleConfig.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type BarkBattleConfig = {
|
||||
roundDurationMs: number;
|
||||
countdownMs: number;
|
||||
drawThreshold: number;
|
||||
barkThreshold: number;
|
||||
minBarkGapMs: number;
|
||||
minBarkDurationMs: number;
|
||||
maxBarkDurationMs: number;
|
||||
balanceFactor: number;
|
||||
calibrationMaxWaitMs: number;
|
||||
opponentBasePower: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_BARK_BATTLE_CONFIG: BarkBattleConfig = {
|
||||
roundDurationMs: 30_000,
|
||||
countdownMs: 3_000,
|
||||
drawThreshold: 12,
|
||||
barkThreshold: 0.5,
|
||||
minBarkGapMs: 300,
|
||||
minBarkDurationMs: 90,
|
||||
maxBarkDurationMs: 900,
|
||||
balanceFactor: 32,
|
||||
calibrationMaxWaitMs: 4_000,
|
||||
opponentBasePower: 0.22,
|
||||
};
|
||||
92
src/games/bark-battle/application/BarkBattleController.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { type BarkBattleSession, createBarkBattleSession } from '../domain/BarkBattleSession';
|
||||
import type { MicrophoneFailureReason } from '../domain/BarkBattleTypes';
|
||||
import { BarkDetector } from '../domain/BarkDetector';
|
||||
import type { BarkBattleConfig } from './BarkBattleConfig';
|
||||
|
||||
export class BarkBattleController {
|
||||
private session: BarkBattleSession;
|
||||
private detector: BarkDetector;
|
||||
private sampleClockMs = 0;
|
||||
|
||||
constructor(private config: BarkBattleConfig) {
|
||||
this.session = createBarkBattleSession(config);
|
||||
this.detector = this.createDetector();
|
||||
}
|
||||
|
||||
getSnapshot() {
|
||||
return this.session.snapshot;
|
||||
}
|
||||
|
||||
getSampleClockMs() {
|
||||
return this.sampleClockMs;
|
||||
}
|
||||
|
||||
updateConfig(config: BarkBattleConfig) {
|
||||
this.config = config;
|
||||
this.restart();
|
||||
}
|
||||
|
||||
finishNow() {
|
||||
if (this.session.snapshot.phase !== 'playing') {
|
||||
this.session = this.session.startMockRound();
|
||||
}
|
||||
if (this.session.snapshot.phase === 'countdown') {
|
||||
this.session = this.session.tick(this.session.snapshot.countdownMs);
|
||||
}
|
||||
this.session = this.session.tick(this.session.snapshot.remainingMs + 1);
|
||||
}
|
||||
|
||||
startWithMockInput() {
|
||||
this.session = createBarkBattleSession(this.config).startMockRound();
|
||||
this.detector = this.createDetector();
|
||||
this.sampleClockMs = 0;
|
||||
}
|
||||
|
||||
forcePlayerBark(volume = 0.9) {
|
||||
if (this.session.snapshot.phase !== 'playing') {
|
||||
this.session = this.session.startMockRound();
|
||||
}
|
||||
if (this.session.snapshot.phase === 'countdown') {
|
||||
this.session = this.session.tick(this.session.snapshot.countdownMs);
|
||||
}
|
||||
this.session = this.session.applyPlayerBark({
|
||||
side: 'player',
|
||||
atMs: this.sampleClockMs,
|
||||
peakVolume: volume,
|
||||
durationMs: this.config.minBarkDurationMs,
|
||||
});
|
||||
}
|
||||
|
||||
submitInputSample(volume: number, atMs = this.sampleClockMs) {
|
||||
const events = this.detector.acceptSample({ atMs, volume });
|
||||
for (const event of events) {
|
||||
this.session = this.session.applyPlayerBark(event);
|
||||
}
|
||||
}
|
||||
|
||||
submitMockSample(volume: number) {
|
||||
this.submitInputSample(volume);
|
||||
}
|
||||
|
||||
tick(deltaMs: number) {
|
||||
this.sampleClockMs += deltaMs;
|
||||
this.session = this.session.tick(deltaMs);
|
||||
}
|
||||
|
||||
restart() {
|
||||
this.session = createBarkBattleSession(this.config);
|
||||
this.detector = this.createDetector();
|
||||
this.sampleClockMs = 0;
|
||||
}
|
||||
|
||||
failMicrophone(reason: MicrophoneFailureReason) {
|
||||
this.session = this.session.failMicrophone(reason);
|
||||
}
|
||||
|
||||
private createDetector() {
|
||||
return new BarkDetector({
|
||||
threshold: this.config.barkThreshold,
|
||||
minBarkGapMs: this.config.minBarkGapMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_BARK_BATTLE_CONFIG } from '../BarkBattleConfig';
|
||||
import { BarkBattleController } from '../BarkBattleController';
|
||||
|
||||
describe('BarkBattleController', () => {
|
||||
it('mock 模式可跑通完整一局并生成结算', () => {
|
||||
const controller = new BarkBattleController({ ...DEFAULT_BARK_BATTLE_CONFIG, roundDurationMs: 1200, countdownMs: 300 });
|
||||
|
||||
controller.startWithMockInput();
|
||||
controller.tick(300);
|
||||
expect(controller.getSnapshot().phase).toBe('playing');
|
||||
|
||||
controller.submitMockSample(0.92);
|
||||
controller.tick(160);
|
||||
controller.submitMockSample(0.12);
|
||||
|
||||
expect(controller.getSnapshot().player.barkCount).toBe(1);
|
||||
expect(controller.getSnapshot().energy).toBeGreaterThan(0);
|
||||
|
||||
controller.tick(1200);
|
||||
expect(controller.getSnapshot().phase).toBe('finished');
|
||||
expect(controller.getSnapshot().result?.winner).toBe('player');
|
||||
});
|
||||
|
||||
it('麦克风失败时进入 unavailable 且不会进入 playing', () => {
|
||||
const controller = new BarkBattleController(DEFAULT_BARK_BATTLE_CONFIG);
|
||||
|
||||
controller.failMicrophone('permission-denied');
|
||||
controller.tick(5000);
|
||||
|
||||
expect(controller.getSnapshot()).toMatchObject({
|
||||
phase: 'unavailable',
|
||||
uiState: 'microphone-unavailable',
|
||||
errorReason: 'permission-denied',
|
||||
statusMessageKey: 'microphone-permission-denied',
|
||||
});
|
||||
});
|
||||
|
||||
it('restart 会重置上一局计数、能量和结果', () => {
|
||||
const controller = new BarkBattleController({ ...DEFAULT_BARK_BATTLE_CONFIG, roundDurationMs: 1, countdownMs: 0 });
|
||||
|
||||
controller.startWithMockInput();
|
||||
controller.tick(1);
|
||||
controller.submitMockSample(1);
|
||||
controller.tick(120);
|
||||
controller.submitMockSample(0.1);
|
||||
controller.tick(2);
|
||||
expect(controller.getSnapshot().result).not.toBeNull();
|
||||
|
||||
controller.restart();
|
||||
expect(controller.getSnapshot().phase).toBe('permission');
|
||||
expect(controller.getSnapshot().player.barkCount).toBe(0);
|
||||
expect(controller.getSnapshot().energy).toBe(0);
|
||||
expect(controller.getSnapshot().result).toBeNull();
|
||||
});
|
||||
|
||||
it('真实输入采样可使用高精度采样时间戳连续触发 100ms 级别叫声', () => {
|
||||
const controller = new BarkBattleController({
|
||||
...DEFAULT_BARK_BATTLE_CONFIG,
|
||||
countdownMs: 0,
|
||||
barkThreshold: 0.5,
|
||||
minBarkDurationMs: 40,
|
||||
minBarkGapMs: 100,
|
||||
});
|
||||
|
||||
controller.startWithMockInput();
|
||||
controller.submitInputSample(0.82, 0);
|
||||
controller.submitInputSample(0.1, 60);
|
||||
controller.submitInputSample(0.9, 120);
|
||||
controller.submitInputSample(0.1, 180);
|
||||
|
||||
expect(controller.getSnapshot().player.barkCount).toBe(2);
|
||||
});
|
||||
});
|
||||
30
src/games/bark-battle/domain/BarkBattleScoring.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { BarkBattleResult, BarkBattleWinner } from './BarkBattleTypes';
|
||||
|
||||
export function decideBarkBattleWinner(
|
||||
energy: number,
|
||||
drawThreshold: number,
|
||||
): BarkBattleWinner {
|
||||
if (energy > drawThreshold) {
|
||||
return 'player';
|
||||
}
|
||||
if (energy < -drawThreshold) {
|
||||
return 'opponent';
|
||||
}
|
||||
return 'draw';
|
||||
}
|
||||
|
||||
export function buildBarkBattleResult(input: {
|
||||
energy: number;
|
||||
drawThreshold: number;
|
||||
playerBarkCount: number;
|
||||
opponentBarkCount: number;
|
||||
}): BarkBattleResult {
|
||||
const winner = decideBarkBattleWinner(input.energy, input.drawThreshold);
|
||||
return {
|
||||
winner,
|
||||
playerBarkCount: input.playerBarkCount,
|
||||
opponentBarkCount: input.opponentBarkCount,
|
||||
finalEnergy: input.energy,
|
||||
score: Math.max(0, Math.round(input.energy + input.playerBarkCount * 120)),
|
||||
};
|
||||
}
|
||||
154
src/games/bark-battle/domain/BarkBattleSession.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { BarkBattleConfig } from '../application/BarkBattleConfig';
|
||||
import { buildBarkBattleResult } from './BarkBattleScoring';
|
||||
import type { BarkBattleEvent, BarkBattleSnapshot } from './BarkBattleTypes';
|
||||
import { advanceEnergy, clampEnergy } from './EnergyTugOfWar';
|
||||
import { computeOpponentPower } from './OpponentStrategy';
|
||||
|
||||
export class BarkBattleSession {
|
||||
constructor(
|
||||
private readonly config: BarkBattleConfig,
|
||||
readonly snapshot: BarkBattleSnapshot,
|
||||
) {}
|
||||
|
||||
startMockRound() {
|
||||
return new BarkBattleSession(this.config, {
|
||||
...this.snapshot,
|
||||
phase: this.config.countdownMs > 0 ? 'countdown' : 'playing',
|
||||
uiState: this.config.countdownMs > 0 ? 'ready-countdown' : 'playing',
|
||||
countdownMs: this.config.countdownMs,
|
||||
remainingMs: this.config.roundDurationMs,
|
||||
lastEvents: [],
|
||||
});
|
||||
}
|
||||
|
||||
tick(deltaMs: number) {
|
||||
if (this.snapshot.phase === 'finished' || this.snapshot.phase === 'unavailable') {
|
||||
return this.withEvents([]);
|
||||
}
|
||||
|
||||
if (this.snapshot.phase === 'countdown') {
|
||||
const countdownMs = Math.max(0, this.snapshot.countdownMs - deltaMs);
|
||||
return new BarkBattleSession(this.config, {
|
||||
...this.snapshot,
|
||||
phase: countdownMs <= 0 ? 'playing' : 'countdown',
|
||||
uiState: countdownMs <= 0 ? 'playing' : 'ready-countdown',
|
||||
countdownMs,
|
||||
remainingMs: this.config.roundDurationMs,
|
||||
lastEvents: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (this.snapshot.phase !== 'playing') {
|
||||
return this.withEvents([]);
|
||||
}
|
||||
|
||||
const elapsedMs = this.snapshot.elapsedMs + deltaMs;
|
||||
const remainingMs = Math.max(0, this.snapshot.remainingMs - deltaMs);
|
||||
const opponentPower = computeOpponentPower(this.config, elapsedMs);
|
||||
const energy = advanceEnergy({
|
||||
energy: this.snapshot.energy,
|
||||
playerPower: this.snapshot.player.power,
|
||||
opponentPower,
|
||||
deltaMs,
|
||||
balanceFactor: this.config.balanceFactor,
|
||||
});
|
||||
const nextSnapshot: BarkBattleSnapshot = {
|
||||
...this.snapshot,
|
||||
elapsedMs,
|
||||
remainingMs,
|
||||
energy,
|
||||
opponent: {
|
||||
...this.snapshot.opponent,
|
||||
power: opponentPower,
|
||||
},
|
||||
player: {
|
||||
...this.snapshot.player,
|
||||
power: Math.max(0, this.snapshot.player.power * 0.78),
|
||||
},
|
||||
lastEvents: [],
|
||||
};
|
||||
|
||||
if (remainingMs > 0) {
|
||||
return new BarkBattleSession(this.config, nextSnapshot);
|
||||
}
|
||||
|
||||
const result = buildBarkBattleResult({
|
||||
energy,
|
||||
drawThreshold: this.config.drawThreshold,
|
||||
playerBarkCount: nextSnapshot.player.barkCount,
|
||||
opponentBarkCount: nextSnapshot.opponent.barkCount,
|
||||
});
|
||||
return new BarkBattleSession(this.config, {
|
||||
...nextSnapshot,
|
||||
phase: 'finished',
|
||||
uiState: 'finished',
|
||||
winner: result.winner,
|
||||
result,
|
||||
});
|
||||
}
|
||||
|
||||
applyPlayerBark(event: BarkBattleEvent) {
|
||||
if (this.snapshot.phase !== 'playing') {
|
||||
return this.withEvents([]);
|
||||
}
|
||||
|
||||
const playerPower = Math.min(1, Math.max(this.snapshot.player.power, event.peakVolume));
|
||||
return new BarkBattleSession(this.config, {
|
||||
...this.snapshot,
|
||||
energy: clampEnergy(this.snapshot.energy + event.peakVolume * 12),
|
||||
player: {
|
||||
barkCount: this.snapshot.player.barkCount + 1,
|
||||
power: playerPower,
|
||||
},
|
||||
lastEvents: [event],
|
||||
});
|
||||
}
|
||||
|
||||
failMicrophone(reason: BarkBattleSnapshot['errorReason']) {
|
||||
return new BarkBattleSession(this.config, {
|
||||
...this.snapshot,
|
||||
phase: 'unavailable',
|
||||
uiState: 'microphone-unavailable',
|
||||
errorReason: reason,
|
||||
statusMessageKey: reason ? MICROPHONE_STATUS_KEYS[reason] : null,
|
||||
lastEvents: [],
|
||||
});
|
||||
}
|
||||
|
||||
private withEvents(lastEvents: BarkBattleEvent[]) {
|
||||
return new BarkBattleSession(this.config, {
|
||||
...this.snapshot,
|
||||
lastEvents,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const MICROPHONE_STATUS_KEYS = {
|
||||
unsupported: 'microphone-unsupported',
|
||||
'permission-denied': 'microphone-permission-denied',
|
||||
'non-secure-context': 'microphone-non-secure-context',
|
||||
'not-found': 'microphone-not-found',
|
||||
'not-readable': 'microphone-not-readable',
|
||||
'audio-context-blocked': 'microphone-audio-context-blocked',
|
||||
'calibration-timeout': 'microphone-calibration-timeout',
|
||||
'calibration-sample-unreadable': 'microphone-calibration-sample-unreadable',
|
||||
unknown: 'microphone-unknown-error',
|
||||
} as const;
|
||||
|
||||
export function createBarkBattleSession(config: BarkBattleConfig) {
|
||||
return new BarkBattleSession(config, {
|
||||
phase: 'permission',
|
||||
uiState: 'permission-ready',
|
||||
errorReason: null,
|
||||
statusMessageKey: null,
|
||||
elapsedMs: 0,
|
||||
remainingMs: config.roundDurationMs,
|
||||
countdownMs: config.countdownMs,
|
||||
energy: 0,
|
||||
player: { barkCount: 0, power: 0 },
|
||||
opponent: { barkCount: 0, power: config.opponentBasePower },
|
||||
winner: null,
|
||||
result: null,
|
||||
lastEvents: [],
|
||||
});
|
||||
}
|
||||
84
src/games/bark-battle/domain/BarkBattleTypes.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
export type BarkBattlePhase =
|
||||
| 'permission'
|
||||
| 'calibration'
|
||||
| 'countdown'
|
||||
| 'playing'
|
||||
| 'finished'
|
||||
| 'unavailable';
|
||||
|
||||
export type BarkBattleSide = 'player' | 'opponent';
|
||||
export type BarkBattleWinner = BarkBattleSide | 'draw' | null;
|
||||
export type BarkBattleDifficulty = 'easy' | 'normal' | 'hard';
|
||||
|
||||
export type BarkBattleUiState =
|
||||
| 'idle'
|
||||
| 'permission-ready'
|
||||
| 'microphone-authorized'
|
||||
| 'calibrating'
|
||||
| 'ready-countdown'
|
||||
| 'playing'
|
||||
| 'finished'
|
||||
| 'microphone-unavailable';
|
||||
|
||||
export type MicrophoneFailureReason =
|
||||
| 'unsupported'
|
||||
| 'permission-denied'
|
||||
| 'non-secure-context'
|
||||
| 'not-found'
|
||||
| 'not-readable'
|
||||
| 'audio-context-blocked'
|
||||
| 'calibration-timeout'
|
||||
| 'calibration-sample-unreadable'
|
||||
| 'unknown';
|
||||
|
||||
export type BarkBattleStatusMessageKey =
|
||||
| 'microphone-unsupported'
|
||||
| 'microphone-permission-denied'
|
||||
| 'microphone-non-secure-context'
|
||||
| 'microphone-not-found'
|
||||
| 'microphone-not-readable'
|
||||
| 'microphone-audio-context-blocked'
|
||||
| 'microphone-calibration-timeout'
|
||||
| 'microphone-calibration-sample-unreadable'
|
||||
| 'microphone-unknown-error';
|
||||
|
||||
export type BarkAudioSample = {
|
||||
atMs: number;
|
||||
volume: number;
|
||||
};
|
||||
|
||||
export type BarkBattleEvent = {
|
||||
side: BarkBattleSide;
|
||||
atMs: number;
|
||||
peakVolume: number;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export type BarkBattleParticipantState = {
|
||||
barkCount: number;
|
||||
power: number;
|
||||
};
|
||||
|
||||
export type BarkBattleResult = {
|
||||
winner: BarkBattleWinner;
|
||||
playerBarkCount: number;
|
||||
opponentBarkCount: number;
|
||||
finalEnergy: number;
|
||||
score: number;
|
||||
};
|
||||
|
||||
export type BarkBattleSnapshot = {
|
||||
phase: BarkBattlePhase;
|
||||
uiState: BarkBattleUiState;
|
||||
errorReason: MicrophoneFailureReason | null;
|
||||
statusMessageKey: BarkBattleStatusMessageKey | null;
|
||||
elapsedMs: number;
|
||||
remainingMs: number;
|
||||
countdownMs: number;
|
||||
energy: number;
|
||||
player: BarkBattleParticipantState;
|
||||
opponent: BarkBattleParticipantState;
|
||||
winner: BarkBattleWinner;
|
||||
result: BarkBattleResult | null;
|
||||
lastEvents: BarkBattleEvent[];
|
||||
};
|
||||
41
src/games/bark-battle/domain/BarkDetector.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { BarkAudioSample, BarkBattleEvent } from './BarkBattleTypes';
|
||||
|
||||
export type BarkDetectorConfig = {
|
||||
threshold: number;
|
||||
minBarkGapMs: number;
|
||||
};
|
||||
|
||||
export class BarkDetector {
|
||||
private lastAcceptedAtMs = Number.NEGATIVE_INFINITY;
|
||||
|
||||
constructor(private readonly config: BarkDetectorConfig) {}
|
||||
|
||||
acceptSample(sample: BarkAudioSample): BarkBattleEvent[] {
|
||||
const volume = clamp01(sample.volume);
|
||||
if (volume < this.config.threshold) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const accepted = sample.atMs - this.lastAcceptedAtMs >= this.config.minBarkGapMs;
|
||||
if (!accepted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this.lastAcceptedAtMs = sample.atMs;
|
||||
return [
|
||||
{
|
||||
side: 'player',
|
||||
atMs: sample.atMs,
|
||||
peakVolume: volume,
|
||||
durationMs: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function clamp01(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
20
src/games/bark-battle/domain/EnergyTugOfWar.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type AdvanceEnergyInput = {
|
||||
energy: number;
|
||||
playerPower: number;
|
||||
opponentPower: number;
|
||||
deltaMs: number;
|
||||
balanceFactor: number;
|
||||
};
|
||||
|
||||
export function advanceEnergy(input: AdvanceEnergyInput) {
|
||||
const deltaSeconds = Math.max(0, input.deltaMs) / 1000;
|
||||
const powerDelta = input.playerPower - input.opponentPower;
|
||||
return clampEnergy(input.energy + powerDelta * input.balanceFactor * deltaSeconds);
|
||||
}
|
||||
|
||||
export function clampEnergy(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(100, Math.max(-100, value));
|
||||
}
|
||||
6
src/games/bark-battle/domain/OpponentStrategy.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { BarkBattleConfig } from '../application/BarkBattleConfig';
|
||||
|
||||
export function computeOpponentPower(config: BarkBattleConfig, elapsedMs: number) {
|
||||
const pulse = 0.05 * Math.sin(elapsedMs / 480);
|
||||
return Math.min(1, Math.max(0, config.opponentBasePower + pulse));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_BARK_BATTLE_CONFIG } from '../../application/BarkBattleConfig';
|
||||
import { decideBarkBattleWinner } from '../BarkBattleScoring';
|
||||
import { createBarkBattleSession } from '../BarkBattleSession';
|
||||
|
||||
describe('BarkBattleSession', () => {
|
||||
it('能从校准完成进入倒计时、playing 并在归零后结算', () => {
|
||||
let session = createBarkBattleSession({ ...DEFAULT_BARK_BATTLE_CONFIG, roundDurationMs: 1000, countdownMs: 600 });
|
||||
|
||||
expect(session.snapshot.phase).toBe('permission');
|
||||
session = session.startMockRound();
|
||||
expect(session.snapshot.phase).toBe('countdown');
|
||||
|
||||
session = session.tick(600);
|
||||
expect(session.snapshot.phase).toBe('playing');
|
||||
expect(session.snapshot.remainingMs).toBe(1000);
|
||||
|
||||
session = session.tick(400);
|
||||
expect(session.snapshot.remainingMs).toBe(600);
|
||||
|
||||
session = session.applyPlayerBark({ atMs: 700, peakVolume: 0.9, durationMs: 140, side: 'player' });
|
||||
expect(session.snapshot.player.barkCount).toBe(1);
|
||||
expect(session.snapshot.energy).toBeGreaterThan(0);
|
||||
|
||||
session = session.tick(600);
|
||||
expect(session.snapshot.phase).toBe('finished');
|
||||
expect(session.snapshot.result?.winner).toBe('player');
|
||||
});
|
||||
|
||||
it('finished 后输入不再改变本局叫声计数和能量', () => {
|
||||
let session = createBarkBattleSession({ ...DEFAULT_BARK_BATTLE_CONFIG, roundDurationMs: 1, countdownMs: 0 }).startMockRound().tick(1);
|
||||
session = session.tick(1);
|
||||
const before = session.snapshot;
|
||||
|
||||
session = session.applyPlayerBark({ atMs: 200, peakVolume: 1, durationMs: 120, side: 'player' });
|
||||
|
||||
expect(session.snapshot.player.barkCount).toBe(before.player.barkCount);
|
||||
expect(session.snapshot.energy).toBe(before.energy);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideBarkBattleWinner', () => {
|
||||
it('按 drawThreshold 判定玩家胜、对手胜和平局', () => {
|
||||
expect(decideBarkBattleWinner(16, 12)).toBe('player');
|
||||
expect(decideBarkBattleWinner(-16, 12)).toBe('opponent');
|
||||
expect(decideBarkBattleWinner(8, 12)).toBe('draw');
|
||||
});
|
||||
});
|
||||
83
src/games/bark-battle/domain/__tests__/BarkDetector.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_BARK_BATTLE_CONFIG } from '../../application/BarkBattleConfig';
|
||||
import { BarkDetector } from '../BarkDetector';
|
||||
|
||||
describe('BarkDetector', () => {
|
||||
it('每个监测点只检测瞬时响度,超过阈值立即触发', () => {
|
||||
const detector = new BarkDetector({
|
||||
threshold: 0.45,
|
||||
minBarkGapMs: DEFAULT_BARK_BATTLE_CONFIG.minBarkGapMs,
|
||||
});
|
||||
|
||||
expect(detector.acceptSample({ atMs: 0, volume: 0.2 })).toEqual([]);
|
||||
const events = detector.acceptSample({ atMs: 40, volume: 0.72 });
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ side: 'player', atMs: 40, peakVolume: 0.72, durationMs: 0 });
|
||||
});
|
||||
|
||||
it('持续噪音按冷却间隔触发,不需要等待响度回落', () => {
|
||||
const detector = new BarkDetector({
|
||||
threshold: 0.4,
|
||||
minBarkGapMs: 250,
|
||||
});
|
||||
|
||||
const allEvents = [
|
||||
...detector.acceptSample({ atMs: 0, volume: 0.7 }),
|
||||
...detector.acceptSample({ atMs: 100, volume: 0.72 }),
|
||||
...detector.acceptSample({ atMs: 200, volume: 0.73 }),
|
||||
...detector.acceptSample({ atMs: 300, volume: 0.75 }),
|
||||
...detector.acceptSample({ atMs: 500, volume: 0.2 }),
|
||||
...detector.acceptSample({ atMs: 560, volume: 0.76 }),
|
||||
];
|
||||
|
||||
expect(allEvents.map((event) => event.atMs)).toEqual([0, 300, 560]);
|
||||
});
|
||||
|
||||
it('低于阈值的背景噪音和冷却内峰值不计数,最短持续时长不再参与判断', () => {
|
||||
const detector = new BarkDetector({
|
||||
threshold: 0.5,
|
||||
minBarkGapMs: 300,
|
||||
});
|
||||
|
||||
expect(detector.acceptSample({ atMs: 0, volume: 0.48 })).toEqual([]);
|
||||
expect(detector.acceptSample({ atMs: 20, volume: 0.9 })).toHaveLength(1);
|
||||
expect(detector.acceptSample({ atMs: 60, volume: 0.95 })).toEqual([]);
|
||||
|
||||
expect(detector.acceptSample({ atMs: 320, volume: 0.88 })).toHaveLength(1);
|
||||
expect(detector.acceptSample({ atMs: 420, volume: 0.2 })).toEqual([]);
|
||||
});
|
||||
|
||||
it('支持 100ms 级别间隔的快速连续有效叫声', () => {
|
||||
const detector = new BarkDetector({
|
||||
threshold: 0.5,
|
||||
minBarkGapMs: 100,
|
||||
});
|
||||
|
||||
const allEvents = [
|
||||
...detector.acceptSample({ atMs: 0, volume: 0.86 }),
|
||||
...detector.acceptSample({ atMs: 60, volume: 0.9 }),
|
||||
...detector.acceptSample({ atMs: 120, volume: 0.91 }),
|
||||
...detector.acceptSample({ atMs: 180, volume: 0.92 }),
|
||||
...detector.acceptSample({ atMs: 240, volume: 0.93 }),
|
||||
];
|
||||
|
||||
expect(allEvents).toHaveLength(3);
|
||||
expect(allEvents.map((event) => event.atMs)).toEqual([0, 120, 240]);
|
||||
});
|
||||
|
||||
it('非有限音量会归零,超过 1 的音量会夹到 1', () => {
|
||||
const detector = new BarkDetector({
|
||||
threshold: 0.5,
|
||||
minBarkGapMs: 100,
|
||||
});
|
||||
|
||||
expect(detector.acceptSample({ atMs: 0, volume: Number.NaN })).toEqual([]);
|
||||
expect(detector.acceptSample({ atMs: 120, volume: Number.POSITIVE_INFINITY })).toEqual([]);
|
||||
const events = detector.acceptSample({ atMs: 240, volume: 2 });
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.peakVolume).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { advanceEnergy } from '../EnergyTugOfWar';
|
||||
|
||||
describe('advanceEnergy', () => {
|
||||
it('玩家推动力高于对手时能量增加', () => {
|
||||
expect(advanceEnergy({ energy: 0, playerPower: 0.8, opponentPower: 0.2, deltaMs: 1000, balanceFactor: 40 })).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('对手推动力高于玩家时能量减少', () => {
|
||||
expect(advanceEnergy({ energy: 0, playerPower: 0.1, opponentPower: 0.7, deltaMs: 1000, balanceFactor: 40 })).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('能量被限制在 -100 到 100 且双方相等时保持稳定', () => {
|
||||
expect(advanceEnergy({ energy: 98, playerPower: 1, opponentPower: 0, deltaMs: 2000, balanceFactor: 40 })).toBe(100);
|
||||
expect(advanceEnergy({ energy: -98, playerPower: 0, opponentPower: 1, deltaMs: 2000, balanceFactor: 40 })).toBe(-100);
|
||||
expect(advanceEnergy({ energy: 12, playerPower: 0.5, opponentPower: 0.5, deltaMs: 1000, balanceFactor: 40 })).toBeCloseTo(12);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { MicrophoneFailureReason } from '../domain/BarkBattleTypes';
|
||||
|
||||
export function mapGetUserMediaError(error: unknown): MicrophoneFailureReason {
|
||||
const name = error && typeof error === 'object' && 'name' in error ? String((error as { name?: unknown }).name) : '';
|
||||
if (name === 'NotAllowedError' || name === 'SecurityError') return 'permission-denied';
|
||||
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') return 'not-found';
|
||||
if (name === 'NotReadableError' || name === 'TrackStartError') return 'not-readable';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function isMicrophoneApiSupported(windowLike: { isSecureContext?: boolean; navigator?: Navigator | { mediaDevices?: { getUserMedia?: unknown } } }) {
|
||||
if (windowLike.isSecureContext === false) {
|
||||
return { ok: false as const, reason: 'non-secure-context' as const };
|
||||
}
|
||||
const getUserMedia = windowLike.navigator?.mediaDevices?.getUserMedia;
|
||||
if (typeof getUserMedia !== 'function') {
|
||||
return { ok: false as const, reason: 'unsupported' as const };
|
||||
}
|
||||
return { ok: true as const, reason: null };
|
||||
}
|
||||
|
||||
export function stopMediaStreamTracks(stream: MediaStream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
|
||||
export type BrowserMicrophoneSampler = {
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
export type BrowserMicrophoneVolumeHandler = (volume: number, atMs: number) => void;
|
||||
|
||||
export async function startBrowserMicrophoneSampler(onVolume: BrowserMicrophoneVolumeHandler): Promise<BrowserMicrophoneSampler> {
|
||||
const supported = isMicrophoneApiSupported(window);
|
||||
if (!supported.ok) {
|
||||
throw Object.assign(new Error(supported.reason), { reason: supported.reason });
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContextCtor) {
|
||||
stopMediaStreamTracks(stream);
|
||||
throw Object.assign(new Error('audio-context-blocked'), { reason: 'audio-context-blocked' });
|
||||
}
|
||||
const audioContext = new AudioContextCtor();
|
||||
if (audioContext.state === 'suspended') {
|
||||
await audioContext.resume();
|
||||
}
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
source.connect(analyser);
|
||||
const data = new Uint8Array(analyser.fftSize);
|
||||
const sampleStartedAtMs = window.performance.now();
|
||||
let rafId = 0;
|
||||
const sample = () => {
|
||||
analyser.getByteTimeDomainData(data);
|
||||
let sum = 0;
|
||||
for (const value of data) {
|
||||
const centered = (value - 128) / 128;
|
||||
sum += centered * centered;
|
||||
}
|
||||
const volume = Math.min(1, Math.sqrt(sum / data.length) * 3.5);
|
||||
onVolume(volume, window.performance.now() - sampleStartedAtMs);
|
||||
rafId = window.requestAnimationFrame(sample);
|
||||
};
|
||||
sample();
|
||||
return {
|
||||
stop: () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
source.disconnect();
|
||||
void audioContext.close();
|
||||
stopMediaStreamTracks(stream);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const reason = error && typeof error === 'object' && 'reason' in error ? (error as { reason: MicrophoneFailureReason }).reason : mapGetUserMediaError(error);
|
||||
throw Object.assign(new Error(reason), { reason });
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { isMicrophoneApiSupported, mapGetUserMediaError, stopMediaStreamTracks } from '../BrowserMicrophoneInput';
|
||||
|
||||
describe('BrowserMicrophoneInput', () => {
|
||||
it('区分非安全上下文和不支持 getUserMedia', () => {
|
||||
expect(isMicrophoneApiSupported({ isSecureContext: false })).toEqual({ ok: false, reason: 'non-secure-context' });
|
||||
expect(isMicrophoneApiSupported({ isSecureContext: true, navigator: {} })).toEqual({ ok: false, reason: 'unsupported' });
|
||||
});
|
||||
|
||||
it('映射常见 getUserMedia 错误', () => {
|
||||
expect(mapGetUserMediaError({ name: 'NotAllowedError' })).toBe('permission-denied');
|
||||
expect(mapGetUserMediaError({ name: 'NotFoundError' })).toBe('not-found');
|
||||
expect(mapGetUserMediaError({ name: 'NotReadableError' })).toBe('not-readable');
|
||||
expect(mapGetUserMediaError({ name: 'OtherError' })).toBe('unknown');
|
||||
});
|
||||
|
||||
it('停止 MediaStream 的所有音轨', () => {
|
||||
const stopA = vi.fn();
|
||||
const stopB = vi.fn();
|
||||
stopMediaStreamTracks({ getTracks: () => [{ stop: stopA }, { stop: stopB }] } as unknown as MediaStream);
|
||||
expect(stopA).toHaveBeenCalledTimes(1);
|
||||
expect(stopB).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
278
src/games/bark-battle/ui/BarkBattleHud.css
Normal file
@@ -0,0 +1,278 @@
|
||||
.bark-battle-hud {
|
||||
min-height: 100svh;
|
||||
color: #fff7ed;
|
||||
background: radial-gradient(circle at 50% 15%, rgba(251, 191, 36, 0.35), transparent 28%), linear-gradient(180deg, #1f1147 0%, #521b4f 48%, #130a28 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: max(18px, env(safe-area-inset-top)) 16px max(18px, env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bark-battle-hud__topline {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bark-battle-hud__timer {
|
||||
justify-self: center;
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(15, 23, 42, 0.56);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.bark-battle-energy {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 247, 237, 0.78);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(15, 23, 42, 0.48);
|
||||
}
|
||||
|
||||
.bark-battle-energy__side--player { background: linear-gradient(90deg, #f97316, #facc15); }
|
||||
.bark-battle-energy__side--opponent { background: linear-gradient(90deg, #60a5fa, #a78bfa); }
|
||||
|
||||
.bark-battle-arena {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto 1fr;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.bark-battle-dog {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
animation: barkBattleDogPulse 420ms ease-out;
|
||||
}
|
||||
|
||||
.bark-battle-dog__body {
|
||||
font-size: clamp(92px, 30vw, 150px);
|
||||
filter: drop-shadow(0 18px 22px rgba(0, 0, 0, 0.42));
|
||||
}
|
||||
|
||||
.bark-battle-dog--player .bark-battle-dog__body {
|
||||
transform: rotateY(180deg) translateY(4px);
|
||||
}
|
||||
|
||||
.bark-battle-dog__label,
|
||||
.bark-battle-dog__burst,
|
||||
.bark-battle-vs {
|
||||
font-weight: 900;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.bark-battle-dog__burst {
|
||||
position: absolute;
|
||||
top: -18px;
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
color: #1f1147;
|
||||
background: #facc15;
|
||||
box-shadow: 0 0 22px rgba(250, 204, 21, 0.72);
|
||||
animation: barkBattleBurst 640ms ease-out both;
|
||||
}
|
||||
|
||||
.bark-battle-dog--opponent .bark-battle-dog__burst {
|
||||
color: #fff7ed;
|
||||
background: #7c3aed;
|
||||
box-shadow: 0 0 22px rgba(124, 58, 237, 0.72);
|
||||
}
|
||||
|
||||
.bark-battle-vs {
|
||||
border-radius: 999px;
|
||||
padding: 10px 18px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.bark-battle-controls,
|
||||
.bark-battle-result__stats {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bark-battle-controls button,
|
||||
.bark-battle-primary-button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
color: #1f1147;
|
||||
background: #fff7ed;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.bark-battle-primary-button {
|
||||
background: linear-gradient(135deg, #facc15, #fb7185);
|
||||
}
|
||||
|
||||
.bark-battle-status-card,
|
||||
.bark-battle-result {
|
||||
margin: auto;
|
||||
width: min(92vw, 420px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 28px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
background: rgba(15, 23, 42, 0.68);
|
||||
box-shadow: 0 26px 60px rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.bark-battle-result__stats span {
|
||||
min-width: 84px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bark-battle-result__stats strong {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.bark-battle-particles {
|
||||
position: absolute;
|
||||
inset: 18% 0 auto;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
font-size: clamp(30px, 10vw, 70px);
|
||||
font-weight: 950;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(255, 247, 237, 0.88);
|
||||
text-shadow: 0 0 18px rgba(250, 204, 21, 0.75);
|
||||
animation: barkBattleParticlePop 820ms ease-out both;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
bottom: max(12px, env(safe-area-inset-bottom));
|
||||
z-index: 8;
|
||||
width: min(78vw, 240px);
|
||||
max-height: 56px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 22px;
|
||||
padding: 10px 12px;
|
||||
color: #fff7ed;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
box-shadow: 0 18px 46px rgba(0, 0, 0, 0.28);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel--expanded {
|
||||
width: min(92vw, 340px);
|
||||
max-height: 42svh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel header,
|
||||
.bark-battle-debug-panel label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel header {
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel__toggle {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
color: #1f1147;
|
||||
background: #facc15;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel__body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel--expanded .bark-battle-debug-panel__body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel label {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel output {
|
||||
min-width: 44px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel__controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-metrics,
|
||||
.bark-battle-debug-events {
|
||||
margin: 10px 0 0;
|
||||
padding: 10px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-metrics__wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.bark-battle-debug-events {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel__controls button {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 8px 10px;
|
||||
color: #1f1147;
|
||||
background: #fff7ed;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@keyframes barkBattleDogPulse {
|
||||
from { transform: scale(1); }
|
||||
45% { transform: scale(1.08); }
|
||||
to { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes barkBattleBurst {
|
||||
from { transform: translateY(18px) scale(0.72); opacity: 0; }
|
||||
35% { opacity: 1; }
|
||||
to { transform: translateY(-38px) scale(1.16); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes barkBattleParticlePop {
|
||||
from { transform: translateY(28px) scale(0.7); opacity: 0; }
|
||||
42% { opacity: 1; }
|
||||
to { transform: translateY(-80px) scale(1.14); opacity: 0; }
|
||||
}
|
||||
97
src/games/bark-battle/ui/BarkBattleHud.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import './BarkBattleHud.css';
|
||||
|
||||
import type { BarkBattleSnapshot } from '../domain/BarkBattleTypes';
|
||||
|
||||
type BarkBattleHudProps = {
|
||||
snapshot: BarkBattleSnapshot;
|
||||
playerPulseKey?: number;
|
||||
opponentPulseKey?: number;
|
||||
onStartMicrophone?: () => void;
|
||||
onMockBark?: () => void;
|
||||
onMockQuiet?: () => void;
|
||||
onRestart?: () => void;
|
||||
};
|
||||
|
||||
const failureText = {
|
||||
unsupported: '当前浏览器不支持麦克风输入',
|
||||
'permission-denied': '麦克风授权被拒绝',
|
||||
'non-secure-context': '当前环境无法使用麦克风',
|
||||
'not-found': '未检测到麦克风',
|
||||
'not-readable': '麦克风暂时不可读',
|
||||
'audio-context-blocked': '音频上下文被拦截',
|
||||
'calibration-timeout': '校准超时',
|
||||
'calibration-sample-unreadable': '校准样本不可读',
|
||||
unknown: '麦克风暂时不可用',
|
||||
};
|
||||
|
||||
export function BarkBattleHud({
|
||||
snapshot,
|
||||
playerPulseKey = 0,
|
||||
opponentPulseKey = 0,
|
||||
onStartMicrophone,
|
||||
onMockBark,
|
||||
onMockQuiet,
|
||||
onRestart,
|
||||
}: BarkBattleHudProps) {
|
||||
const playerWidth = `${Math.round(((snapshot.energy + 100) / 200) * 100)}%`;
|
||||
const opponentWidth = `${Math.round(((100 - snapshot.energy) / 200) * 100)}%`;
|
||||
const isUnavailable = snapshot.phase === 'unavailable';
|
||||
|
||||
return (
|
||||
<section className="bark-battle-hud" aria-label="汪汪声浪大作战">
|
||||
<header className="bark-battle-hud__topline">
|
||||
<div className="bark-battle-hud__timer">{(snapshot.remainingMs / 1000).toFixed(1)}s</div>
|
||||
<div
|
||||
className="bark-battle-energy"
|
||||
role="meter"
|
||||
aria-label="声浪能量条"
|
||||
aria-valuemin={-100}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(snapshot.energy)}
|
||||
>
|
||||
<div className="bark-battle-energy__side bark-battle-energy__side--player" data-testid="player-energy-fill" style={{ width: playerWidth }} />
|
||||
<div className="bark-battle-energy__side bark-battle-energy__side--opponent" data-testid="opponent-energy-fill" style={{ width: opponentWidth }} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isUnavailable ? (
|
||||
<div className="bark-battle-status-card">
|
||||
<h1>{snapshot.errorReason ? failureText[snapshot.errorReason] : '麦克风暂时不可用'}</h1>
|
||||
{snapshot.errorReason !== 'unsupported' ? (
|
||||
<button type="button" className="bark-battle-primary-button" onClick={onStartMicrophone}>
|
||||
{snapshot.errorReason === 'permission-denied' ? '重新授权' : '重试'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bark-battle-arena" aria-label="竖屏声浪竞技场">
|
||||
<div key={`player-${playerPulseKey}`} className="bark-battle-dog bark-battle-dog--player" aria-label="玩家狗狗背对屏幕">
|
||||
<span className="bark-battle-dog__burst" aria-hidden="true">汪</span>
|
||||
<span className="bark-battle-dog__body">🐕</span>
|
||||
<span className="bark-battle-dog__label">你 · {snapshot.player.barkCount}</span>
|
||||
</div>
|
||||
<div className="bark-battle-vs">VS</div>
|
||||
<div key={`opponent-${opponentPulseKey}`} className="bark-battle-dog bark-battle-dog--opponent" aria-label="对手狗狗面向屏幕">
|
||||
<span className="bark-battle-dog__burst" aria-hidden="true">反击</span>
|
||||
<span className="bark-battle-dog__body">🐶</span>
|
||||
<span className="bark-battle-dog__label">对手 · {snapshot.opponent.barkCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="bark-battle-controls">
|
||||
{snapshot.phase === 'permission' ? (
|
||||
<button className="bark-battle-primary-button" type="button" onClick={onStartMicrophone}>
|
||||
开始声控
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onPointerDown={onMockBark} onPointerUp={onMockQuiet} onClick={onMockBark}>
|
||||
模拟叫声
|
||||
</button>
|
||||
{snapshot.phase === 'finished' ? (
|
||||
<button type="button" onClick={onRestart}>再来一局</button>
|
||||
) : null}
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
34
src/games/bark-battle/ui/BarkBattleResultPanel.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { BarkBattleResult } from '../domain/BarkBattleTypes';
|
||||
|
||||
type BarkBattleResultPanelProps = {
|
||||
result: BarkBattleResult;
|
||||
onRestart: () => void;
|
||||
};
|
||||
|
||||
export function BarkBattleResultPanel({ result, onRestart }: BarkBattleResultPanelProps) {
|
||||
const title = result.winner === 'player' ? '汪力压制成功' : result.winner === 'opponent' ? '对手声浪更强' : '势均力敌';
|
||||
|
||||
return (
|
||||
<section className="bark-battle-result" role="dialog" aria-label="对战结算">
|
||||
<p className="bark-battle-result__eyebrow">本局结束</p>
|
||||
<h2>{title}</h2>
|
||||
<div className="bark-battle-result__stats">
|
||||
<span>
|
||||
<strong>{result.playerBarkCount}</strong>
|
||||
玩家叫声
|
||||
</span>
|
||||
<span>
|
||||
<strong>{result.opponentBarkCount}</strong>
|
||||
对手压制
|
||||
</span>
|
||||
<span>
|
||||
<strong>{result.score}</strong>
|
||||
声浪分
|
||||
</span>
|
||||
</div>
|
||||
<button className="bark-battle-primary-button" type="button" onClick={onRestart}>
|
||||
再来一局
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
265
src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
type BarkBattleConfig,
|
||||
DEFAULT_BARK_BATTLE_CONFIG,
|
||||
} from '../application/BarkBattleConfig';
|
||||
import { BarkBattleController } from '../application/BarkBattleController';
|
||||
import type { MicrophoneFailureReason } from '../domain/BarkBattleTypes';
|
||||
import {
|
||||
type BrowserMicrophoneSampler,
|
||||
startBrowserMicrophoneSampler,
|
||||
} from '../infrastructure/BrowserMicrophoneInput';
|
||||
import { BarkBattleHud } from './BarkBattleHud';
|
||||
import { BarkBattleResultPanel } from './BarkBattleResultPanel';
|
||||
|
||||
type BarkBattleRuntimeShellProps = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type DebugEvent = {
|
||||
id: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const DEBUG_CONFIG_FIELDS: Array<{
|
||||
key: keyof Pick<
|
||||
BarkBattleConfig,
|
||||
| 'roundDurationMs'
|
||||
| 'countdownMs'
|
||||
| 'drawThreshold'
|
||||
| 'barkThreshold'
|
||||
| 'minBarkGapMs'
|
||||
| 'balanceFactor'
|
||||
| 'opponentBasePower'
|
||||
>;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
}> = [
|
||||
{ key: 'roundDurationMs', label: '局长(ms)', min: 1000, max: 60000, step: 1000 },
|
||||
{ key: 'countdownMs', label: '倒计时(ms)', min: 0, max: 5000, step: 500 },
|
||||
{ key: 'drawThreshold', label: '平局阈值', min: 0, max: 40, step: 1 },
|
||||
{ key: 'barkThreshold', label: '叫声阈值', min: 0.1, max: 1, step: 0.05 },
|
||||
{ key: 'minBarkGapMs', label: '叫声间隔(ms)', min: 100, max: 1200, step: 50 },
|
||||
{ key: 'balanceFactor', label: '拉锯速度', min: 5, max: 80, step: 1 },
|
||||
{ key: 'opponentBasePower', label: '对手基础力', min: 0, max: 1, step: 0.05 },
|
||||
];
|
||||
|
||||
const MICROPHONE_FAILURE_REASONS = new Set<MicrophoneFailureReason>([
|
||||
'unsupported',
|
||||
'permission-denied',
|
||||
'non-secure-context',
|
||||
'not-found',
|
||||
'not-readable',
|
||||
'audio-context-blocked',
|
||||
'calibration-timeout',
|
||||
'calibration-sample-unreadable',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
function isMicrophoneFailureReason(reason: unknown): reason is MicrophoneFailureReason {
|
||||
return typeof reason === 'string' && MICROPHONE_FAILURE_REASONS.has(reason as MicrophoneFailureReason);
|
||||
}
|
||||
|
||||
export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: BarkBattleRuntimeShellProps) {
|
||||
const [config, setConfig] = useState(DEFAULT_BARK_BATTLE_CONFIG);
|
||||
const controllerRef = useRef<BarkBattleController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new BarkBattleController(config);
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
const [snapshot, setSnapshot] = useState(() => controller.getSnapshot());
|
||||
const [particleText, setParticleText] = useState('');
|
||||
const [inputMode, setInputMode] = useState<'mock' | 'microphone'>('mock');
|
||||
const [liveInputVolume, setLiveInputVolume] = useState(0);
|
||||
const [isDebugExpanded, setIsDebugExpanded] = useState(false);
|
||||
const [playerPulseKey, setPlayerPulseKey] = useState(0);
|
||||
const [opponentPulseKey, setOpponentPulseKey] = useState(0);
|
||||
const [debugEvents, setDebugEvents] = useState<DebugEvent[]>([]);
|
||||
const heldRef = useRef(false);
|
||||
const lastPlayerBarkCountRef = useRef(0);
|
||||
const lastOpponentPowerRef = useRef(0);
|
||||
const debugEventIdRef = useRef(0);
|
||||
const microphoneSamplerRef = useRef<BrowserMicrophoneSampler | null>(null);
|
||||
|
||||
const appendDebugEvent = useCallback((text: string) => {
|
||||
debugEventIdRef.current += 1;
|
||||
const event = { id: debugEventIdRef.current, text };
|
||||
setDebugEvents((current) => [event, ...current].slice(0, 5));
|
||||
}, []);
|
||||
|
||||
const syncSnapshot = useCallback(() => {
|
||||
const nextSnapshot = controller.getSnapshot();
|
||||
if (nextSnapshot.player.barkCount > lastPlayerBarkCountRef.current) {
|
||||
setPlayerPulseKey((current) => current + 1);
|
||||
appendDebugEvent(`玩家叫声触发 #${nextSnapshot.player.barkCount} · 能量 ${Math.round(nextSnapshot.energy)}`);
|
||||
}
|
||||
if (nextSnapshot.phase === 'playing' && Math.abs(nextSnapshot.opponent.power - lastOpponentPowerRef.current) >= 0.08) {
|
||||
setOpponentPulseKey((current) => current + 1);
|
||||
appendDebugEvent(`对手反击强度 ${(nextSnapshot.opponent.power * 100).toFixed(0)}%`);
|
||||
}
|
||||
lastPlayerBarkCountRef.current = nextSnapshot.player.barkCount;
|
||||
lastOpponentPowerRef.current = nextSnapshot.opponent.power;
|
||||
setSnapshot(nextSnapshot);
|
||||
}, [appendDebugEvent, controller]);
|
||||
|
||||
const stopMicrophone = useCallback(() => {
|
||||
microphoneSamplerRef.current?.stop();
|
||||
microphoneSamplerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const startMicrophone = useCallback(async () => {
|
||||
stopMicrophone();
|
||||
try {
|
||||
controller.startWithMockInput();
|
||||
const sampler = await startBrowserMicrophoneSampler((volume, atMs) => {
|
||||
setLiveInputVolume(volume);
|
||||
if (volume >= config.barkThreshold) {
|
||||
appendDebugEvent(`麦克风输入 ${(volume * 100).toFixed(0)}%`);
|
||||
}
|
||||
controller.submitInputSample(volume, atMs);
|
||||
});
|
||||
microphoneSamplerRef.current = sampler;
|
||||
setInputMode('microphone');
|
||||
appendDebugEvent('真实麦克风已开启');
|
||||
syncSnapshot();
|
||||
} catch (error) {
|
||||
const reason = error && typeof error === 'object' && 'reason' in error ? error.reason : 'unknown';
|
||||
const failureReason = isMicrophoneFailureReason(reason) ? reason : 'unknown';
|
||||
controller.failMicrophone(failureReason);
|
||||
appendDebugEvent(`麦克风不可用:${failureReason}`);
|
||||
syncSnapshot();
|
||||
}
|
||||
}, [appendDebugEvent, config.barkThreshold, controller, stopMicrophone, syncSnapshot]);
|
||||
|
||||
useEffect(() => stopMicrophone, [stopMicrophone]);
|
||||
|
||||
useEffect(() => {
|
||||
controller.updateConfig(config);
|
||||
syncSnapshot();
|
||||
}, [config, controller, syncSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
controller.tick(100);
|
||||
if (inputMode === 'mock') {
|
||||
if (heldRef.current) {
|
||||
controller.submitMockSample(0.88);
|
||||
} else {
|
||||
controller.submitMockSample(0.12);
|
||||
setLiveInputVolume(0);
|
||||
}
|
||||
}
|
||||
syncSnapshot();
|
||||
}, 100);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [controller, inputMode, syncSnapshot]);
|
||||
|
||||
const restart = () => {
|
||||
heldRef.current = false;
|
||||
stopMicrophone();
|
||||
setInputMode('mock');
|
||||
setLiveInputVolume(0);
|
||||
controller.restart();
|
||||
setParticleText('');
|
||||
setDebugEvents([]);
|
||||
lastPlayerBarkCountRef.current = 0;
|
||||
lastOpponentPowerRef.current = 0;
|
||||
syncSnapshot();
|
||||
};
|
||||
|
||||
const startMock = () => {
|
||||
stopMicrophone();
|
||||
setInputMode('mock');
|
||||
setLiveInputVolume(0);
|
||||
controller.startWithMockInput();
|
||||
appendDebugEvent('开始 mock 对局(不会请求浏览器麦克风权限)');
|
||||
syncSnapshot();
|
||||
};
|
||||
|
||||
const finishNow = () => {
|
||||
heldRef.current = false;
|
||||
stopMicrophone();
|
||||
controller.finishNow();
|
||||
appendDebugEvent('人工结束对局');
|
||||
syncSnapshot();
|
||||
};
|
||||
|
||||
const bark = () => {
|
||||
controller.forcePlayerBark(0.9);
|
||||
syncSnapshot();
|
||||
setParticleText('汪!');
|
||||
window.setTimeout(() => setParticleText(''), 680);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="bark-battle-runtime" aria-label={title}>
|
||||
<BarkBattleHud
|
||||
snapshot={snapshot}
|
||||
playerPulseKey={playerPulseKey}
|
||||
opponentPulseKey={opponentPulseKey}
|
||||
onStartMicrophone={startMicrophone}
|
||||
onMockBark={bark}
|
||||
onMockQuiet={() => {
|
||||
heldRef.current = false;
|
||||
}}
|
||||
onRestart={restart}
|
||||
/>
|
||||
<aside className={`bark-battle-debug-panel${isDebugExpanded ? ' bark-battle-debug-panel--expanded' : ''}`} aria-label="调试面板">
|
||||
<header>
|
||||
<strong>调试面板</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="bark-battle-debug-panel__toggle"
|
||||
aria-expanded={isDebugExpanded}
|
||||
onClick={() => setIsDebugExpanded((current) => !current)}
|
||||
>
|
||||
{isDebugExpanded ? '收起' : '展开'}
|
||||
</button>
|
||||
<span>{snapshot.phase}</span>
|
||||
</header>
|
||||
<div className="bark-battle-debug-panel__body">
|
||||
<div className="bark-battle-debug-panel__controls">
|
||||
<button type="button" onClick={startMock}>开始</button>
|
||||
<button type="button" onClick={finishNow}>结束</button>
|
||||
<button type="button" onClick={restart}>重置</button>
|
||||
</div>
|
||||
<div className="bark-battle-debug-metrics" aria-label="触发反馈">
|
||||
<span className="bark-battle-debug-metrics__wide">输入模式:{inputMode === 'microphone' ? '真实麦克风' : 'Mock 输入'}</span>
|
||||
<span>实时音量:{(liveInputVolume * 100).toFixed(0)}%</span>
|
||||
<span>采样时钟:{controller.getSampleClockMs()}ms</span>
|
||||
<span>玩家触发:{snapshot.player.barkCount}</span>
|
||||
<span>玩家强度:{(snapshot.player.power * 100).toFixed(0)}%</span>
|
||||
<span>对手强度:{(snapshot.opponent.power * 100).toFixed(0)}%</span>
|
||||
<span>能量:{Math.round(snapshot.energy)}</span>
|
||||
</div>
|
||||
<ol className="bark-battle-debug-events" aria-label="触发日志">
|
||||
{debugEvents.length ? debugEvents.map((event) => <li key={event.id}>{event.text}</li>) : <li>等待输入触发</li>}
|
||||
</ol>
|
||||
{DEBUG_CONFIG_FIELDS.map((field) => (
|
||||
<label key={field.key}>
|
||||
<span>{field.label}</span>
|
||||
<input
|
||||
aria-label={field.label}
|
||||
type="range"
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
value={config[field.key]}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.currentTarget.value);
|
||||
setConfig((current) => ({ ...current, [field.key]: value }));
|
||||
}}
|
||||
/>
|
||||
<output>{config[field.key]}</output>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
{particleText ? <div className="bark-battle-particles">{particleText}</div> : null}
|
||||
{snapshot.result ? <BarkBattleResultPanel result={snapshot.result} onRestart={restart} /> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
57
src/games/bark-battle/ui/__tests__/BarkBattleHud.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { BarkBattleSnapshot } from '../../domain/BarkBattleTypes';
|
||||
import { BarkBattleHud } from '../BarkBattleHud';
|
||||
|
||||
function buildSnapshot(overrides: Partial<BarkBattleSnapshot> = {}): BarkBattleSnapshot {
|
||||
return {
|
||||
phase: 'playing',
|
||||
uiState: 'playing',
|
||||
errorReason: null,
|
||||
statusMessageKey: null,
|
||||
elapsedMs: 0,
|
||||
remainingMs: 12_000,
|
||||
countdownMs: 0,
|
||||
energy: 40,
|
||||
player: { barkCount: 3, power: 0.8 },
|
||||
opponent: { barkCount: 1, power: 0.25 },
|
||||
winner: null,
|
||||
result: null,
|
||||
lastEvents: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BarkBattleHud', () => {
|
||||
it('playing 阶段展示竖屏核心元素、倒计时和双方狗狗朝向', () => {
|
||||
render(<BarkBattleHud snapshot={buildSnapshot()} onMockBark={() => {}} onMockQuiet={() => {}} />);
|
||||
|
||||
expect(screen.getByText('12.0s')).toBeTruthy();
|
||||
expect(screen.getByLabelText('玩家狗狗背对屏幕')).toBeTruthy();
|
||||
expect(screen.getByLabelText('对手狗狗面向屏幕')).toBeTruthy();
|
||||
expect(screen.getByLabelText('声浪能量条').getAttribute('aria-valuenow')).toBe('40');
|
||||
});
|
||||
|
||||
it('energy 正负值会改变玩家侧和对手侧占比', () => {
|
||||
const { rerender } = render(<BarkBattleHud snapshot={buildSnapshot({ energy: 60 })} />);
|
||||
expect(screen.getByTestId('player-energy-fill').getAttribute('style')).toContain('width: 80%');
|
||||
|
||||
rerender(<BarkBattleHud snapshot={buildSnapshot({ energy: -60 })} />);
|
||||
expect(screen.getByTestId('opponent-energy-fill').getAttribute('style')).toContain('width: 80%');
|
||||
});
|
||||
|
||||
it('unsupported 不展示开始声控按钮,permission-denied 展示重试授权入口', () => {
|
||||
const { rerender } = render(
|
||||
<BarkBattleHud snapshot={buildSnapshot({ phase: 'unavailable', errorReason: 'unsupported' })} onStartMicrophone={() => {}} />,
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: '开始声控' })).toBeNull();
|
||||
|
||||
rerender(
|
||||
<BarkBattleHud snapshot={buildSnapshot({ phase: 'unavailable', errorReason: 'permission-denied' })} onStartMicrophone={() => {}} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '重新授权' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BarkBattleResultPanel } from '../BarkBattleResultPanel';
|
||||
|
||||
describe('BarkBattleResultPanel', () => {
|
||||
it('展示胜负、叫声次数并支持再来一局', async () => {
|
||||
const onRestart = vi.fn();
|
||||
render(
|
||||
<BarkBattleResultPanel
|
||||
result={{ winner: 'player', playerBarkCount: 6, opponentBarkCount: 2, finalEnergy: 72, score: 792 }}
|
||||
onRestart={onRestart}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '对战结算' })).toBeTruthy();
|
||||
expect(screen.getByText('汪力压制成功')).toBeTruthy();
|
||||
expect(screen.getByText('6')).toBeTruthy();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '再来一局' }));
|
||||
expect(onRestart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BarkBattleRuntimeShell } from '../BarkBattleRuntimeShell';
|
||||
|
||||
describe('BarkBattleRuntimeShell 调试面板', () => {
|
||||
it('提供开始、结束、重置流程控制按钮和参数滑杆', async () => {
|
||||
render(<BarkBattleRuntimeShell />);
|
||||
|
||||
const debugPanel = screen.getByLabelText('调试面板');
|
||||
expect(debugPanel).toBeTruthy();
|
||||
expect(within(debugPanel).getByRole('button', { name: '展开' })).toBeTruthy();
|
||||
|
||||
await userEvent.click(within(debugPanel).getByRole('button', { name: '展开' }));
|
||||
expect(within(debugPanel).getByRole('button', { name: '收起' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '开始' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '结束' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '重置' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('叫声阈值')).toBeTruthy();
|
||||
expect(screen.getByLabelText('触发反馈')).toBeTruthy();
|
||||
expect(screen.getByLabelText('触发日志')).toBeTruthy();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '开始' }));
|
||||
expect(screen.getByText(/countdown|playing/u)).toBeTruthy();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
|
||||
expect(screen.getAllByText(/玩家叫声触发 #1/u).length).toBeGreaterThan(0);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '结束' }));
|
||||
expect(screen.getByRole('dialog', { name: '对战结算' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('真实声控入口在不支持麦克风时展示失败原因,mock 开始不请求权限', async () => {
|
||||
render(<BarkBattleRuntimeShell />);
|
||||
|
||||
const debugPanel = screen.getByLabelText('调试面板');
|
||||
await userEvent.click(within(debugPanel).getByRole('button', { name: '展开' }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '开始声控' }));
|
||||
|
||||
expect(screen.getByText('当前浏览器不支持麦克风输入')).toBeTruthy();
|
||||
expect(screen.getAllByText(/麦克风不可用:unsupported/u).length).toBeGreaterThan(0);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '开始' }));
|
||||
expect(screen.getAllByText(/开始 mock 对局(不会请求浏览器麦克风权限)/u).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/输入模式:Mock 输入/u)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,9 @@ export type AppRouteMatch =
|
||||
| {
|
||||
kind: 'match3d-playground';
|
||||
}
|
||||
| {
|
||||
kind: 'bark-battle-playground';
|
||||
}
|
||||
| {
|
||||
kind: 'child-motion-demo';
|
||||
}
|
||||
@@ -37,6 +40,7 @@ export type ResolvedAppRoute = {
|
||||
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
|
||||
const BigFishPlaygroundApp = lazy(() => import('../BigFishPlaygroundApp')) as AppRouteComponent;
|
||||
const Match3DPlaygroundApp = lazy(() => import('../Match3DPlaygroundApp')) as AppRouteComponent;
|
||||
const BarkBattlePlaygroundApp = lazy(() => import('../BarkBattlePlaygroundApp')) as AppRouteComponent;
|
||||
const PuzzlePlaygroundApp = lazy(() => import('../PuzzlePlaygroundApp')) as AppRouteComponent;
|
||||
const ChildMotionDemoApp = lazy(() => import('../ChildMotionDemoApp')) as AppRouteComponent;
|
||||
|
||||
@@ -65,6 +69,12 @@ export function matchAppRoute(pathname: string): AppRouteMatch {
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedPath === '/bark-battle') {
|
||||
return {
|
||||
kind: 'bark-battle-playground',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedPath === '/child-motion-demo' &&
|
||||
isEdutainmentEntryEnabled()
|
||||
@@ -109,6 +119,15 @@ export function resolveAppRoute(pathname: string): ResolvedAppRoute {
|
||||
};
|
||||
}
|
||||
|
||||
if (matchedRoute.kind === 'bark-battle-playground') {
|
||||
return {
|
||||
kind: 'bark-battle-playground',
|
||||
loadingEyebrow: '正在载入汪汪声浪',
|
||||
loadingText: '正在进入竖屏声浪竞技场...',
|
||||
Component: BarkBattlePlaygroundApp,
|
||||
};
|
||||
}
|
||||
|
||||
if (matchedRoute.kind === 'child-motion-demo') {
|
||||
return {
|
||||
kind: 'child-motion-demo',
|
||||
|
||||
@@ -6,6 +6,11 @@ export {
|
||||
startLocalMatch3DRun,
|
||||
stopLocalMatch3DRun,
|
||||
} from './match3dLocalRuntime';
|
||||
export {
|
||||
createLocalMatch3DRuntimeAdapter,
|
||||
createServerMatch3DRuntimeAdapter,
|
||||
type Match3DRuntimeAdapter,
|
||||
} from './match3dRuntimeAdapter';
|
||||
export {
|
||||
clickMatch3DItem,
|
||||
finishMatch3DTimeUp,
|
||||
|
||||
@@ -481,12 +481,17 @@ export function buildLocalMatch3DOptimisticRun(
|
||||
};
|
||||
}
|
||||
|
||||
function waitForLocalConfirmation(delayMs: number) {
|
||||
const scheduler = globalThis.setTimeout;
|
||||
return new Promise((resolve) => scheduler(resolve, delayMs));
|
||||
}
|
||||
|
||||
export async function confirmLocalMatch3DClick(
|
||||
run: Match3DRunSnapshot,
|
||||
request: Match3DClickItemRequest,
|
||||
): Promise<Match3DClickItemResult> {
|
||||
// 中文注释:F3 阶段用本地函数模拟后端权威确认,真实接口接入后保留同一结果语义。
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 180));
|
||||
await waitForLocalConfirmation(180);
|
||||
const timedRun = normalizeRemainingMs(run);
|
||||
if (timedRun.status !== 'Running') {
|
||||
return {
|
||||
|
||||
144
src/services/match3d-runtime/match3dRuntimeAdapter.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
Match3DRunResponse,
|
||||
Match3DRunSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import {
|
||||
createLocalMatch3DRuntimeAdapter,
|
||||
createServerMatch3DRuntimeAdapter,
|
||||
type Match3DRuntimeAdapter,
|
||||
startLocalMatch3DRun,
|
||||
} from './index';
|
||||
|
||||
function buildMockRun(runId: string): Match3DRunSnapshot {
|
||||
return {
|
||||
runId,
|
||||
profileId: 'server-profile-1',
|
||||
ownerUserId: 'server-owner-1',
|
||||
status: 'Running',
|
||||
snapshotVersion: 1,
|
||||
startedAtMs: 1_700_000_000_000,
|
||||
durationLimitMs: 30_000,
|
||||
serverNowMs: 1_700_000_000_000,
|
||||
remainingMs: 30_000,
|
||||
clearCount: 3,
|
||||
totalItemCount: 0,
|
||||
clearedItemCount: 0,
|
||||
boardVersion: 1,
|
||||
items: [],
|
||||
traySlots: [],
|
||||
failureReason: null,
|
||||
lastConfirmedActionId: null,
|
||||
};
|
||||
}
|
||||
|
||||
test('server Match3D runtime adapter forwards the full runtime seam lazily', async () => {
|
||||
const startResponse: Match3DRunResponse = { run: buildMockRun('server-run-start') };
|
||||
const getResponse: Match3DRunResponse = { run: buildMockRun('server-run-get') };
|
||||
const restartResponse: Match3DRunResponse = { run: buildMockRun('server-run-restart') };
|
||||
const stopResponse: Match3DRunResponse = {
|
||||
run: { ...buildMockRun('server-run-stop'), status: 'Stopped' },
|
||||
};
|
||||
const finishResponse: Match3DRunResponse = {
|
||||
run: { ...buildMockRun('server-run-finish'), status: 'Timeout' },
|
||||
};
|
||||
const clickPayload: Match3DClickItemRequest = {
|
||||
runId: 'server-run-start',
|
||||
itemInstanceId: 'item-1',
|
||||
clientActionId: 'action-1',
|
||||
clientEventId: 'event-1',
|
||||
clickedAtMs: 1_700_000_000_001,
|
||||
clientSnapshotVersion: 1,
|
||||
};
|
||||
const dependencies = {
|
||||
clickItem: vi.fn().mockResolvedValue({
|
||||
status: 'Accepted' as const,
|
||||
run: buildMockRun('server-run-click'),
|
||||
}),
|
||||
finishTimeUp: vi.fn().mockResolvedValue(finishResponse),
|
||||
getRun: vi.fn().mockResolvedValue(getResponse),
|
||||
restartRun: vi.fn().mockResolvedValue(restartResponse),
|
||||
startRun: vi.fn().mockResolvedValue(startResponse),
|
||||
stopRun: vi.fn().mockResolvedValue(stopResponse),
|
||||
};
|
||||
const adapter = createServerMatch3DRuntimeAdapter(dependencies);
|
||||
|
||||
expect(await adapter.startRun('server-profile-1', { skipRefresh: true })).toBe(
|
||||
startResponse,
|
||||
);
|
||||
expect(await adapter.getRun('server-run-start')).toBe(getResponse);
|
||||
expect(await adapter.clickItem('server-run-start', clickPayload)).toEqual({
|
||||
status: 'Accepted',
|
||||
run: buildMockRun('server-run-click'),
|
||||
});
|
||||
expect(await adapter.restartRun('server-run-start')).toBe(restartResponse);
|
||||
expect(await adapter.stopRun('server-run-restart')).toBe(stopResponse);
|
||||
expect(await adapter.finishTimeUp('server-run-start')).toBe(finishResponse);
|
||||
|
||||
expect(dependencies.startRun).toHaveBeenCalledWith('server-profile-1', {
|
||||
skipRefresh: true,
|
||||
});
|
||||
expect(dependencies.getRun).toHaveBeenCalledWith('server-run-start');
|
||||
expect(dependencies.clickItem).toHaveBeenCalledWith(
|
||||
'server-run-start',
|
||||
clickPayload,
|
||||
);
|
||||
expect(dependencies.restartRun).toHaveBeenCalledWith('server-run-start');
|
||||
expect(dependencies.stopRun).toHaveBeenCalledWith('server-run-restart');
|
||||
expect(dependencies.finishTimeUp).toHaveBeenCalledWith('server-run-start');
|
||||
});
|
||||
|
||||
test('local Match3D runtime adapter exposes the same runtime seam as the server client', async () => {
|
||||
const adapter = createLocalMatch3DRuntimeAdapter({ clearCount: 1 });
|
||||
const started = await adapter.startRun('ignored-local-profile');
|
||||
const clickableItem = started.run.items.find((item) => item.clickable);
|
||||
|
||||
expect(started.run.profileId).toBe('local-match3d-profile');
|
||||
expect(clickableItem).toBeTruthy();
|
||||
|
||||
const clickResult = await adapter.clickItem(started.run.runId, {
|
||||
runId: started.run.runId,
|
||||
itemInstanceId: clickableItem!.itemInstanceId,
|
||||
clientActionId: 'local-click-1',
|
||||
clientEventId: 'local-event-1',
|
||||
clickedAtMs: started.run.serverNowMs ?? Date.now(),
|
||||
clientSnapshotVersion: started.run.snapshotVersion,
|
||||
});
|
||||
|
||||
expect(clickResult.status).toBe('Accepted');
|
||||
expect(clickResult.run.snapshotVersion).toBe(started.run.snapshotVersion + 1);
|
||||
|
||||
const restarted = await adapter.restartRun(started.run.runId);
|
||||
expect(restarted.run.runId).not.toBe(started.run.runId);
|
||||
|
||||
const stopped = await adapter.stopRun(restarted.run.runId);
|
||||
expect(stopped.run.status).toBe('Stopped');
|
||||
});
|
||||
|
||||
test('local Match3D runtime adapter keeps authority run local to the adapter', async () => {
|
||||
const adapter = createLocalMatch3DRuntimeAdapter({ initialRun: startLocalMatch3DRun(1) });
|
||||
const first = await adapter.getRun('unused-run-id');
|
||||
const timedOut = await adapter.finishTimeUp(first.run.runId);
|
||||
|
||||
expect(timedOut.run.status).toBe('Running');
|
||||
expect(timedOut.run.runId).toBe(first.run.runId);
|
||||
});
|
||||
|
||||
test('server and local Match3D runtime adapters share the same runtime seam', () => {
|
||||
const adapters: Match3DRuntimeAdapter[] = [
|
||||
createLocalMatch3DRuntimeAdapter({ clearCount: 1 }),
|
||||
createServerMatch3DRuntimeAdapter(),
|
||||
];
|
||||
|
||||
expect(adapters).toHaveLength(2);
|
||||
for (const adapter of adapters) {
|
||||
expect(typeof adapter.startRun).toBe('function');
|
||||
expect(typeof adapter.getRun).toBe('function');
|
||||
expect(typeof adapter.clickItem).toBe('function');
|
||||
expect(typeof adapter.restartRun).toBe('function');
|
||||
expect(typeof adapter.stopRun).toBe('function');
|
||||
expect(typeof adapter.finishTimeUp).toBe('function');
|
||||
}
|
||||
});
|
||||
106
src/services/match3d-runtime/match3dRuntimeAdapter.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
Match3DClickItemResult,
|
||||
Match3DRunResponse,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import {
|
||||
confirmLocalMatch3DClick,
|
||||
resolveLocalMatch3DTimer,
|
||||
startLocalMatch3DRun,
|
||||
stopLocalMatch3DRun,
|
||||
} from './match3dLocalRuntime';
|
||||
import {
|
||||
clickMatch3DItem,
|
||||
finishMatch3DTimeUp,
|
||||
getMatch3DRun,
|
||||
type Match3DRuntimeRequestOptions,
|
||||
restartMatch3DRun,
|
||||
startMatch3DRun,
|
||||
stopMatch3DRun,
|
||||
} from './match3dRuntimeClient';
|
||||
|
||||
export type Match3DRuntimeAdapter = {
|
||||
startRun: (
|
||||
profileId: string,
|
||||
options?: Match3DRuntimeRequestOptions,
|
||||
) => Promise<Match3DRunResponse>;
|
||||
getRun: (runId: string) => Promise<Match3DRunResponse>;
|
||||
clickItem: (
|
||||
runId: string,
|
||||
payload: Match3DClickItemRequest,
|
||||
) => Promise<Match3DClickItemResult>;
|
||||
restartRun: (runId: string) => Promise<Match3DRunResponse>;
|
||||
stopRun: (runId: string) => Promise<Match3DRunResponse>;
|
||||
finishTimeUp: (runId: string) => Promise<Match3DRunResponse>;
|
||||
};
|
||||
|
||||
export type LocalMatch3DRuntimeAdapterOptions = {
|
||||
clearCount?: number;
|
||||
initialRun?: Match3DRunResponse['run'];
|
||||
};
|
||||
|
||||
type ServerMatch3DRuntimeAdapterDependencies = {
|
||||
clickItem: typeof clickMatch3DItem;
|
||||
finishTimeUp: typeof finishMatch3DTimeUp;
|
||||
getRun: typeof getMatch3DRun;
|
||||
restartRun: typeof restartMatch3DRun;
|
||||
startRun: typeof startMatch3DRun;
|
||||
stopRun: typeof stopMatch3DRun;
|
||||
};
|
||||
|
||||
const defaultServerMatch3DRuntimeAdapterDependencies: ServerMatch3DRuntimeAdapterDependencies = {
|
||||
clickItem: clickMatch3DItem,
|
||||
finishTimeUp: finishMatch3DTimeUp,
|
||||
getRun: getMatch3DRun,
|
||||
restartRun: restartMatch3DRun,
|
||||
startRun: startMatch3DRun,
|
||||
stopRun: stopMatch3DRun,
|
||||
};
|
||||
|
||||
export function createServerMatch3DRuntimeAdapter(
|
||||
dependencies: ServerMatch3DRuntimeAdapterDependencies =
|
||||
defaultServerMatch3DRuntimeAdapterDependencies,
|
||||
): Match3DRuntimeAdapter {
|
||||
return {
|
||||
clickItem: (runId, payload) => dependencies.clickItem(runId, payload),
|
||||
finishTimeUp: (runId) => dependencies.finishTimeUp(runId),
|
||||
getRun: (runId) => dependencies.getRun(runId),
|
||||
restartRun: (runId) => dependencies.restartRun(runId),
|
||||
startRun: (profileId, options) => dependencies.startRun(profileId, options),
|
||||
stopRun: (runId) => dependencies.stopRun(runId),
|
||||
};
|
||||
}
|
||||
|
||||
export function createLocalMatch3DRuntimeAdapter(
|
||||
options: LocalMatch3DRuntimeAdapterOptions = {},
|
||||
): Match3DRuntimeAdapter {
|
||||
let authorityRun = options.initialRun ?? startLocalMatch3DRun(options.clearCount);
|
||||
|
||||
return {
|
||||
async startRun() {
|
||||
authorityRun = startLocalMatch3DRun(options.clearCount);
|
||||
return { run: authorityRun };
|
||||
},
|
||||
async getRun() {
|
||||
authorityRun = resolveLocalMatch3DTimer(authorityRun);
|
||||
return { run: authorityRun };
|
||||
},
|
||||
async clickItem(_runId, payload) {
|
||||
const result = await confirmLocalMatch3DClick(authorityRun, payload);
|
||||
authorityRun = result.run;
|
||||
return result;
|
||||
},
|
||||
async restartRun() {
|
||||
authorityRun = startLocalMatch3DRun(options.clearCount);
|
||||
return { run: authorityRun };
|
||||
},
|
||||
async stopRun() {
|
||||
authorityRun = stopLocalMatch3DRun(authorityRun);
|
||||
return { run: authorityRun };
|
||||
},
|
||||
async finishTimeUp() {
|
||||
authorityRun = resolveLocalMatch3DTimer(authorityRun);
|
||||
return { run: authorityRun };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -24,7 +24,7 @@ const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
type Match3DRuntimeRequestOptions = Pick<
|
||||
export type Match3DRuntimeRequestOptions = Pick<
|
||||
ApiRequestOptions,
|
||||
| 'authImpact'
|
||||
| 'skipRefresh'
|
||||
|
||||