feat: complete bark battle playable demo
This commit is contained in:
@@ -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"
|
||||
|
||||
14
AGENTS.md
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
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
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
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.
|
||||
@@ -7,8 +7,8 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import type {
|
||||
PlatformCreationTypeCard,
|
||||
PlatformCreationTypeId,
|
||||
@@ -185,6 +185,20 @@ export function CustomWorldCreationHub({
|
||||
canDeleteSquareHole: 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,
|
||||
}),
|
||||
[
|
||||
bigFishItems,
|
||||
@@ -196,6 +210,14 @@ export function CustomWorldCreationHub({
|
||||
onDeletePublished,
|
||||
onDeletePuzzle,
|
||||
onDeleteVisualNovel,
|
||||
onClaimPuzzlePointIncentive,
|
||||
onOpenBigFishDetail,
|
||||
onOpenDraft,
|
||||
onOpenMatch3DDetail,
|
||||
onOpenPuzzleDetail,
|
||||
onOpenSquareHoleDetail,
|
||||
onOpenVisualNovelDetail,
|
||||
onEnterPublished,
|
||||
puzzleItems,
|
||||
rpgLibraryEntries,
|
||||
squareHoleItems,
|
||||
@@ -222,89 +244,16 @@ export function CustomWorldCreationHub({
|
||||
[activeFilter, shelfItems],
|
||||
);
|
||||
|
||||
function handleOpenShelfItem(item: CreationWorkShelfItem) {
|
||||
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';
|
||||
@@ -373,7 +322,7 @@ export function CustomWorldCreationHub({
|
||||
previousMetricValues={
|
||||
metricSnapshot[buildWorkMetricCacheItemKey(item)]
|
||||
}
|
||||
onOpen={() => handleOpenShelfItem(item)}
|
||||
onOpen={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);
|
||||
});
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||
import {
|
||||
buildBigFishPublicWorkCode,
|
||||
@@ -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;
|
||||
@@ -97,6 +103,7 @@ export type CreationWorkShelfItem = {
|
||||
badges: CreationWorkShelfBadge[];
|
||||
metrics: CreationWorkShelfMetric[];
|
||||
pointIncentive?: CreationWorkShelfPointIncentive;
|
||||
actions: CreationWorkShelfActions;
|
||||
source: CreationWorkShelfSource;
|
||||
};
|
||||
|
||||
@@ -114,6 +121,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;
|
||||
}) {
|
||||
const {
|
||||
rpgItems,
|
||||
@@ -129,26 +150,60 @@ export function buildCreationWorkShelfItems(params: {
|
||||
canDeleteSquareHole = false,
|
||||
canDeletePuzzle = false,
|
||||
canDeleteVisualNovel = false,
|
||||
onOpenRpgDraft,
|
||||
onEnterRpgPublished,
|
||||
onDeleteRpg,
|
||||
onOpenBigFishDetail,
|
||||
onDeleteBigFish,
|
||||
onOpenMatch3DDetail,
|
||||
onDeleteMatch3D,
|
||||
onOpenSquareHoleDetail,
|
||||
onDeleteSquareHole,
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle,
|
||||
onClaimPuzzlePointIncentive,
|
||||
onOpenVisualNovelDetail,
|
||||
onDeleteVisualNovel,
|
||||
} = 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,
|
||||
}),
|
||||
),
|
||||
].sort(
|
||||
(left, right) =>
|
||||
@@ -156,10 +211,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
|
||||
@@ -200,6 +271,7 @@ function mapRpgWorkToShelfItem(
|
||||
: '查看详情',
|
||||
canDelete,
|
||||
canShare: item.status === 'published' && Boolean(publicWorkCode),
|
||||
actions: buildRpgWorkShelfActions(item, adapter),
|
||||
badges,
|
||||
metrics: isDraft ? [] : metrics,
|
||||
source: { kind: 'rpg', item },
|
||||
@@ -209,6 +281,7 @@ function mapRpgWorkToShelfItem(
|
||||
function mapBigFishWorkToShelfItem(
|
||||
item: BigFishWorkSummary,
|
||||
canDelete: boolean,
|
||||
adapter: WorkShelfAdapter<BigFishWorkSummary>,
|
||||
): CreationWorkShelfItem {
|
||||
const isPublished = item.status === 'published';
|
||||
const publicWorkCode = isPublished
|
||||
@@ -233,6 +306,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' },
|
||||
@@ -251,6 +325,7 @@ function mapBigFishWorkToShelfItem(
|
||||
function mapMatch3DWorkToShelfItem(
|
||||
item: Match3DWorkSummary,
|
||||
canDelete: boolean,
|
||||
adapter: WorkShelfAdapter<Match3DWorkSummary>,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
|
||||
const publicWorkCode =
|
||||
@@ -274,6 +349,7 @@ function mapMatch3DWorkToShelfItem(
|
||||
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
|
||||
canDelete,
|
||||
canShare: status === 'published' && Boolean(publicWorkCode),
|
||||
actions: buildWorkShelfActions(item, adapter),
|
||||
badges: [
|
||||
buildStatusBadge(status),
|
||||
{ id: 'type', label: '抓鹅', tone: 'neutral' },
|
||||
@@ -293,6 +369,7 @@ function mapMatch3DWorkToShelfItem(
|
||||
function mapPuzzleWorkToShelfItem(
|
||||
item: PuzzleWorkSummary,
|
||||
canDelete: boolean,
|
||||
adapter: PuzzleWorkShelfAdapter,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus;
|
||||
const publicWorkCode =
|
||||
@@ -320,6 +397,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' },
|
||||
@@ -354,6 +432,7 @@ function mapPuzzleWorkToShelfItem(
|
||||
function mapVisualNovelWorkToShelfItem(
|
||||
item: VisualNovelWorkSummary,
|
||||
canDelete: boolean,
|
||||
adapter: WorkShelfAdapter<VisualNovelWorkSummary>,
|
||||
): CreationWorkShelfItem {
|
||||
const status =
|
||||
item.publishStatus === 'published' ? 'published' : 'draft';
|
||||
@@ -394,6 +473,7 @@ function mapVisualNovelWorkToShelfItem(
|
||||
likeCount: 0,
|
||||
})
|
||||
: [],
|
||||
actions: buildWorkShelfActions(item, adapter),
|
||||
source: { kind: 'visual-novel', item },
|
||||
};
|
||||
}
|
||||
@@ -401,6 +481,7 @@ function mapVisualNovelWorkToShelfItem(
|
||||
function mapSquareHoleWorkToShelfItem(
|
||||
item: SquareHoleWorkSummary,
|
||||
canDelete: boolean,
|
||||
adapter: WorkShelfAdapter<SquareHoleWorkSummary>,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
|
||||
const publicWorkCode =
|
||||
@@ -438,10 +519,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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type BarkBattleSession,createBarkBattleSession } from '../domain/BarkBattleSession';
|
||||
import { type BarkBattleSession, createBarkBattleSession } from '../domain/BarkBattleSession';
|
||||
import type { MicrophoneFailureReason } from '../domain/BarkBattleTypes';
|
||||
import { BarkDetector } from '../domain/BarkDetector';
|
||||
import type { BarkBattleConfig } from './BarkBattleConfig';
|
||||
@@ -17,6 +17,10 @@ export class BarkBattleController {
|
||||
return this.session.snapshot;
|
||||
}
|
||||
|
||||
getSampleClockMs() {
|
||||
return this.sampleClockMs;
|
||||
}
|
||||
|
||||
updateConfig(config: BarkBattleConfig) {
|
||||
this.config = config;
|
||||
this.restart();
|
||||
@@ -38,13 +42,32 @@ export class BarkBattleController {
|
||||
this.sampleClockMs = 0;
|
||||
}
|
||||
|
||||
submitMockSample(volume: number) {
|
||||
const events = this.detector.acceptSample({ atMs: this.sampleClockMs, volume });
|
||||
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);
|
||||
@@ -64,8 +87,6 @@ export class BarkBattleController {
|
||||
return new BarkDetector({
|
||||
threshold: this.config.barkThreshold,
|
||||
minBarkGapMs: this.config.minBarkGapMs,
|
||||
minBarkDurationMs: this.config.minBarkDurationMs,
|
||||
maxBarkDurationMs: this.config.maxBarkDurationMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,4 +54,22 @@ describe('BarkBattleController', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,59 +3,31 @@ import type { BarkAudioSample, BarkBattleEvent } from './BarkBattleTypes';
|
||||
export type BarkDetectorConfig = {
|
||||
threshold: number;
|
||||
minBarkGapMs: number;
|
||||
minBarkDurationMs: number;
|
||||
maxBarkDurationMs: number;
|
||||
};
|
||||
|
||||
type ActiveBark = {
|
||||
startMs: number;
|
||||
peakVolume: number;
|
||||
};
|
||||
|
||||
export class BarkDetector {
|
||||
private activeBark: ActiveBark | null = null;
|
||||
private lastAcceptedAtMs = Number.NEGATIVE_INFINITY;
|
||||
|
||||
constructor(private readonly config: BarkDetectorConfig) {}
|
||||
|
||||
acceptSample(sample: BarkAudioSample): BarkBattleEvent[] {
|
||||
const volume = clamp01(sample.volume);
|
||||
if (volume >= this.config.threshold) {
|
||||
this.activeBark = this.activeBark
|
||||
? {
|
||||
startMs: this.activeBark.startMs,
|
||||
peakVolume: Math.max(this.activeBark.peakVolume, volume),
|
||||
}
|
||||
: {
|
||||
startMs: sample.atMs,
|
||||
peakVolume: volume,
|
||||
};
|
||||
if (volume < this.config.threshold) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!this.activeBark) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const activeBark = this.activeBark;
|
||||
this.activeBark = null;
|
||||
const durationMs = sample.atMs - activeBark.startMs;
|
||||
const accepted =
|
||||
durationMs >= this.config.minBarkDurationMs &&
|
||||
durationMs <= this.config.maxBarkDurationMs &&
|
||||
activeBark.startMs - this.lastAcceptedAtMs >= this.config.minBarkGapMs;
|
||||
|
||||
const accepted = sample.atMs - this.lastAcceptedAtMs >= this.config.minBarkGapMs;
|
||||
if (!accepted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this.lastAcceptedAtMs = activeBark.startMs;
|
||||
this.lastAcceptedAtMs = sample.atMs;
|
||||
return [
|
||||
{
|
||||
side: 'player',
|
||||
atMs: activeBark.startMs,
|
||||
peakVolume: activeBark.peakVolume,
|
||||
durationMs,
|
||||
atMs: sample.atMs,
|
||||
peakVolume: volume,
|
||||
durationMs: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,30 +4,23 @@ import { DEFAULT_BARK_BATTLE_CONFIG } from '../../application/BarkBattleConfig';
|
||||
import { BarkDetector } from '../BarkDetector';
|
||||
|
||||
describe('BarkDetector', () => {
|
||||
it('超过阈值且持续时长合规时只计为一次有效叫声', () => {
|
||||
it('每个监测点只检测瞬时响度,超过阈值立即触发', () => {
|
||||
const detector = new BarkDetector({
|
||||
threshold: 0.45,
|
||||
minBarkGapMs: DEFAULT_BARK_BATTLE_CONFIG.minBarkGapMs,
|
||||
minBarkDurationMs: 90,
|
||||
maxBarkDurationMs: 900,
|
||||
});
|
||||
|
||||
expect(detector.acceptSample({ atMs: 0, volume: 0.2 })).toEqual([]);
|
||||
expect(detector.acceptSample({ atMs: 40, volume: 0.72 })).toEqual([]);
|
||||
expect(detector.acceptSample({ atMs: 150, volume: 0.76 })).toEqual([]);
|
||||
const events = detector.acceptSample({ atMs: 180, volume: 0.2 });
|
||||
const events = detector.acceptSample({ atMs: 40, volume: 0.72 });
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ side: 'player', peakVolume: 0.76 });
|
||||
expect(events[0]?.durationMs).toBe(140);
|
||||
expect(events[0]).toMatchObject({ side: 'player', atMs: 40, peakVolume: 0.72, durationMs: 0 });
|
||||
});
|
||||
|
||||
it('持续噪音不会在每个 tick 无限计数', () => {
|
||||
it('持续噪音按冷却间隔触发,不需要等待响度回落', () => {
|
||||
const detector = new BarkDetector({
|
||||
threshold: 0.4,
|
||||
minBarkGapMs: 250,
|
||||
minBarkDurationMs: 80,
|
||||
maxBarkDurationMs: 600,
|
||||
});
|
||||
|
||||
const allEvents = [
|
||||
@@ -36,27 +29,55 @@ describe('BarkDetector', () => {
|
||||
...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).toHaveLength(1);
|
||||
expect(allEvents.map((event) => event.atMs)).toEqual([0, 300, 560]);
|
||||
});
|
||||
|
||||
it('低于阈值的背景噪音、过短脉冲和冷却内峰值不计数', () => {
|
||||
it('低于阈值的背景噪音和冷却内峰值不计数,最短持续时长不再参与判断', () => {
|
||||
const detector = new BarkDetector({
|
||||
threshold: 0.5,
|
||||
minBarkGapMs: 300,
|
||||
minBarkDurationMs: 80,
|
||||
maxBarkDurationMs: 800,
|
||||
});
|
||||
|
||||
expect(detector.acceptSample({ atMs: 0, volume: 0.48 })).toEqual([]);
|
||||
detector.acceptSample({ atMs: 20, volume: 0.9 });
|
||||
expect(detector.acceptSample({ atMs: 60, volume: 0.2 })).toEqual([]);
|
||||
expect(detector.acceptSample({ atMs: 20, volume: 0.9 })).toHaveLength(1);
|
||||
expect(detector.acceptSample({ atMs: 60, volume: 0.95 })).toEqual([]);
|
||||
|
||||
detector.acceptSample({ atMs: 500, volume: 0.88 });
|
||||
expect(detector.acceptSample({ atMs: 620, volume: 0.2 })).toHaveLength(1);
|
||||
expect(detector.acceptSample({ atMs: 320, volume: 0.88 })).toHaveLength(1);
|
||||
expect(detector.acceptSample({ atMs: 420, volume: 0.2 })).toEqual([]);
|
||||
});
|
||||
|
||||
detector.acceptSample({ atMs: 700, volume: 0.9 });
|
||||
expect(detector.acceptSample({ atMs: 820, 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,3 +22,64 @@ export function isMicrophoneApiSupported(windowLike: { isSecureContext?: boolean
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,18 +155,24 @@
|
||||
right: 12px;
|
||||
bottom: max(12px, env(safe-area-inset-bottom));
|
||||
z-index: 8;
|
||||
width: min(92vw, 340px);
|
||||
max-height: 42svh;
|
||||
overflow: auto;
|
||||
width: min(78vw, 240px);
|
||||
max-height: 56px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 22px;
|
||||
padding: 12px;
|
||||
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;
|
||||
@@ -175,6 +181,28 @@
|
||||
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;
|
||||
@@ -211,6 +239,10 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-metrics__wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.bark-battle-debug-events {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
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';
|
||||
|
||||
@@ -42,6 +47,22 @@ const DEBUG_CONFIG_FIELDS: Array<{
|
||||
{ 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);
|
||||
@@ -51,6 +72,9 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
|
||||
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[]>([]);
|
||||
@@ -58,6 +82,7 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
|
||||
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;
|
||||
@@ -80,6 +105,37 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
|
||||
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();
|
||||
@@ -88,18 +144,24 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
|
||||
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, syncSnapshot]);
|
||||
}, [controller, inputMode, syncSnapshot]);
|
||||
|
||||
const restart = () => {
|
||||
heldRef.current = false;
|
||||
stopMicrophone();
|
||||
setInputMode('mock');
|
||||
setLiveInputVolume(0);
|
||||
controller.restart();
|
||||
setParticleText('');
|
||||
setDebugEvents([]);
|
||||
@@ -109,22 +171,25 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
|
||||
};
|
||||
|
||||
const startMock = () => {
|
||||
stopMicrophone();
|
||||
setInputMode('mock');
|
||||
setLiveInputVolume(0);
|
||||
controller.startWithMockInput();
|
||||
appendDebugEvent('开始 mock 对局');
|
||||
appendDebugEvent('开始 mock 对局(不会请求浏览器麦克风权限)');
|
||||
syncSnapshot();
|
||||
};
|
||||
|
||||
const finishNow = () => {
|
||||
heldRef.current = false;
|
||||
stopMicrophone();
|
||||
controller.finishNow();
|
||||
appendDebugEvent('人工结束对局');
|
||||
syncSnapshot();
|
||||
};
|
||||
|
||||
const bark = () => {
|
||||
heldRef.current = true;
|
||||
setPlayerPulseKey((current) => current + 1);
|
||||
appendDebugEvent('按下模拟叫声按钮');
|
||||
controller.forcePlayerBark(0.9);
|
||||
syncSnapshot();
|
||||
setParticleText('汪!');
|
||||
window.setTimeout(() => setParticleText(''), 680);
|
||||
};
|
||||
@@ -135,24 +200,36 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
|
||||
snapshot={snapshot}
|
||||
playerPulseKey={playerPulseKey}
|
||||
opponentPulseKey={opponentPulseKey}
|
||||
onStartMicrophone={startMock}
|
||||
onStartMicrophone={startMicrophone}
|
||||
onMockBark={bark}
|
||||
onMockQuiet={() => {
|
||||
heldRef.current = false;
|
||||
}}
|
||||
onRestart={restart}
|
||||
/>
|
||||
<aside className="bark-battle-debug-panel" aria-label="调试面板">
|
||||
<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>
|
||||
@@ -179,6 +256,7 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
|
||||
<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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -10,7 +10,12 @@ describe('BarkBattleRuntimeShell 调试面板', () => {
|
||||
it('提供开始、结束、重置流程控制按钮和参数滑杆', async () => {
|
||||
render(<BarkBattleRuntimeShell />);
|
||||
|
||||
expect(screen.getByLabelText('调试面板')).toBeTruthy();
|
||||
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();
|
||||
@@ -22,9 +27,25 @@ describe('BarkBattleRuntimeShell 调试面板', () => {
|
||||
expect(screen.getByText(/countdown|playing/u)).toBeTruthy();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
|
||||
expect(screen.getAllByText('按下模拟叫声按钮').length).toBeGreaterThan(0);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user