feat: complete bark battle playable demo
This commit is contained in:
@@ -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 (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}
|
||||
/>
|
||||
<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__controls">
|
||||
<button type="button" onClick={startMock}>开始</button>
|
||||
<button type="button" onClick={finishNow}>结束</button>
|
||||
<button type="button" onClick={restart}>重置</button>
|
||||
<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>
|
||||
<div className="bark-battle-debug-metrics" aria-label="触发反馈">
|
||||
<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>
|
||||
))}
|
||||
</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