From 33c9079d3b451787be54c24885026381fde800e5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 12 May 2026 14:42:58 +0800 Subject: [PATCH] feat: complete bark battle playable demo --- .env.local | 2 - AGENTS.md | 14 ++ docs/agents/domain.md | 36 ++++ docs/agents/issue-tracker.md | 35 ++++ docs/agents/triage-labels.md | 15 ++ .../CustomWorldCreationHub.tsx | 103 +++-------- .../creationWorkShelf.test.ts | 38 +++- .../custom-world-home/creationWorkShelf.ts | 150 +++++++++++++++- .../application/BarkBattleController.ts | 31 +++- .../__tests__/BarkBattleController.test.ts | 18 ++ src/games/bark-battle/domain/BarkDetector.ts | 40 +---- .../domain/__tests__/BarkDetector.test.ts | 63 ++++--- .../infrastructure/BrowserMicrophoneInput.ts | 61 +++++++ src/games/bark-battle/ui/BarkBattleHud.css | 40 ++++- .../bark-battle/ui/BarkBattleRuntimeShell.tsx | 162 +++++++++++++----- .../__tests__/BarkBattleRuntimeShell.test.tsx | 27 ++- 16 files changed, 639 insertions(+), 196 deletions(-) create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md diff --git a/.env.local b/.env.local index 7635ae86..34b87a66 100644 --- a/.env.local +++ b/.env.local @@ -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" diff --git a/AGENTS.md b/AGENTS.md index 5cb4c83b..6970172b 100644 --- a/AGENTS.md +++ b/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`. + ## 项目约束 - 代码需要有完善的中文注释 - 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。 diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 00000000..a42a7ace --- /dev/null +++ b/docs/agents/domain.md @@ -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. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 00000000..f4b84310 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -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 ` +- 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. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 00000000..71d37f37 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -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. diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 87a338a4..e93d0c65 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -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)} diff --git a/src/components/custom-world-home/creationWorkShelf.test.ts b/src/components/custom-world-home/creationWorkShelf.test.ts index b9cd428d..9d2b38a0 100644 --- a/src/components/custom-world-home/creationWorkShelf.test.ts +++ b/src/components/custom-world-home/creationWorkShelf.test.ts @@ -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); +}); diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index 545fbf63..6c5417dc 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -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 = { + onOpen?: (item: TItem) => void; + onDelete?: (item: TItem) => void; +}; + +type PuzzleWorkShelfAdapter = WorkShelfAdapter & { + onClaimPointIncentive?: (item: PuzzleWorkSummary) => void; +}; + function mapRpgWorkToShelfItem( item: CustomWorldWorkSummary, canDelete: boolean, libraryEntries: CustomWorldLibraryEntry[], + 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, ): 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, ): 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, ): 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, ): 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( + item: TItem, + adapter: WorkShelfAdapter, +): 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; diff --git a/src/games/bark-battle/application/BarkBattleController.ts b/src/games/bark-battle/application/BarkBattleController.ts index b6ccd01c..7b0ab61f 100644 --- a/src/games/bark-battle/application/BarkBattleController.ts +++ b/src/games/bark-battle/application/BarkBattleController.ts @@ -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, }); } } diff --git a/src/games/bark-battle/application/__tests__/BarkBattleController.test.ts b/src/games/bark-battle/application/__tests__/BarkBattleController.test.ts index d5454c92..a82c7b86 100644 --- a/src/games/bark-battle/application/__tests__/BarkBattleController.test.ts +++ b/src/games/bark-battle/application/__tests__/BarkBattleController.test.ts @@ -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); + }); }); diff --git a/src/games/bark-battle/domain/BarkDetector.ts b/src/games/bark-battle/domain/BarkDetector.ts index 39060f60..48568bf7 100644 --- a/src/games/bark-battle/domain/BarkDetector.ts +++ b/src/games/bark-battle/domain/BarkDetector.ts @@ -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, }, ]; } diff --git a/src/games/bark-battle/domain/__tests__/BarkDetector.test.ts b/src/games/bark-battle/domain/__tests__/BarkDetector.test.ts index c349ec78..20edf368 100644 --- a/src/games/bark-battle/domain/__tests__/BarkDetector.test.ts +++ b/src/games/bark-battle/domain/__tests__/BarkDetector.test.ts @@ -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); }); }); diff --git a/src/games/bark-battle/infrastructure/BrowserMicrophoneInput.ts b/src/games/bark-battle/infrastructure/BrowserMicrophoneInput.ts index a6820a31..b4a545a5 100644 --- a/src/games/bark-battle/infrastructure/BrowserMicrophoneInput.ts +++ b/src/games/bark-battle/infrastructure/BrowserMicrophoneInput.ts @@ -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 { + 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; + } +} diff --git a/src/games/bark-battle/ui/BarkBattleHud.css b/src/games/bark-battle/ui/BarkBattleHud.css index b4763410..65aded4d 100644 --- a/src/games/bark-battle/ui/BarkBattleHud.css +++ b/src/games/bark-battle/ui/BarkBattleHud.css @@ -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; diff --git a/src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx b/src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx index add39648..d4fd9ba7 100644 --- a/src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx +++ b/src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx @@ -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([ + '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(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([]); @@ -58,6 +82,7 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark const lastPlayerBarkCountRef = useRef(0); const lastOpponentPowerRef = useRef(0); const debugEventIdRef = useRef(0); + const microphoneSamplerRef = useRef(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 (heldRef.current) { - controller.submitMockSample(0.88); - } else { - controller.submitMockSample(0.12); + 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,50 +200,63 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark snapshot={snapshot} playerPulseKey={playerPulseKey} opponentPulseKey={opponentPulseKey} - onStartMicrophone={startMock} + onStartMicrophone={startMicrophone} onMockBark={bark} onMockQuiet={() => { heldRef.current = false; }} onRestart={restart} /> -