feat: add bark battle browser prototype

This commit is contained in:
2026-05-11 18:01:55 +08:00
parent bf72c2e48d
commit 2b046656dc
32 changed files with 2244 additions and 18 deletions

View File

@@ -0,0 +1,25 @@
export type BarkBattleConfig = {
roundDurationMs: number;
countdownMs: number;
drawThreshold: number;
barkThreshold: number;
minBarkGapMs: number;
minBarkDurationMs: number;
maxBarkDurationMs: number;
balanceFactor: number;
calibrationMaxWaitMs: number;
opponentBasePower: number;
};
export const DEFAULT_BARK_BATTLE_CONFIG: BarkBattleConfig = {
roundDurationMs: 30_000,
countdownMs: 3_000,
drawThreshold: 12,
barkThreshold: 0.5,
minBarkGapMs: 300,
minBarkDurationMs: 90,
maxBarkDurationMs: 900,
balanceFactor: 32,
calibrationMaxWaitMs: 4_000,
opponentBasePower: 0.22,
};

View File

@@ -0,0 +1,71 @@
import { type BarkBattleSession,createBarkBattleSession } from '../domain/BarkBattleSession';
import type { MicrophoneFailureReason } from '../domain/BarkBattleTypes';
import { BarkDetector } from '../domain/BarkDetector';
import type { BarkBattleConfig } from './BarkBattleConfig';
export class BarkBattleController {
private session: BarkBattleSession;
private detector: BarkDetector;
private sampleClockMs = 0;
constructor(private config: BarkBattleConfig) {
this.session = createBarkBattleSession(config);
this.detector = this.createDetector();
}
getSnapshot() {
return this.session.snapshot;
}
updateConfig(config: BarkBattleConfig) {
this.config = config;
this.restart();
}
finishNow() {
if (this.session.snapshot.phase !== 'playing') {
this.session = this.session.startMockRound();
}
if (this.session.snapshot.phase === 'countdown') {
this.session = this.session.tick(this.session.snapshot.countdownMs);
}
this.session = this.session.tick(this.session.snapshot.remainingMs + 1);
}
startWithMockInput() {
this.session = createBarkBattleSession(this.config).startMockRound();
this.detector = this.createDetector();
this.sampleClockMs = 0;
}
submitMockSample(volume: number) {
const events = this.detector.acceptSample({ atMs: this.sampleClockMs, volume });
for (const event of events) {
this.session = this.session.applyPlayerBark(event);
}
}
tick(deltaMs: number) {
this.sampleClockMs += deltaMs;
this.session = this.session.tick(deltaMs);
}
restart() {
this.session = createBarkBattleSession(this.config);
this.detector = this.createDetector();
this.sampleClockMs = 0;
}
failMicrophone(reason: MicrophoneFailureReason) {
this.session = this.session.failMicrophone(reason);
}
private createDetector() {
return new BarkDetector({
threshold: this.config.barkThreshold,
minBarkGapMs: this.config.minBarkGapMs,
minBarkDurationMs: this.config.minBarkDurationMs,
maxBarkDurationMs: this.config.maxBarkDurationMs,
});
}
}

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_BARK_BATTLE_CONFIG } from '../BarkBattleConfig';
import { BarkBattleController } from '../BarkBattleController';
describe('BarkBattleController', () => {
it('mock 模式可跑通完整一局并生成结算', () => {
const controller = new BarkBattleController({ ...DEFAULT_BARK_BATTLE_CONFIG, roundDurationMs: 1200, countdownMs: 300 });
controller.startWithMockInput();
controller.tick(300);
expect(controller.getSnapshot().phase).toBe('playing');
controller.submitMockSample(0.92);
controller.tick(160);
controller.submitMockSample(0.12);
expect(controller.getSnapshot().player.barkCount).toBe(1);
expect(controller.getSnapshot().energy).toBeGreaterThan(0);
controller.tick(1200);
expect(controller.getSnapshot().phase).toBe('finished');
expect(controller.getSnapshot().result?.winner).toBe('player');
});
it('麦克风失败时进入 unavailable 且不会进入 playing', () => {
const controller = new BarkBattleController(DEFAULT_BARK_BATTLE_CONFIG);
controller.failMicrophone('permission-denied');
controller.tick(5000);
expect(controller.getSnapshot()).toMatchObject({
phase: 'unavailable',
uiState: 'microphone-unavailable',
errorReason: 'permission-denied',
statusMessageKey: 'microphone-permission-denied',
});
});
it('restart 会重置上一局计数、能量和结果', () => {
const controller = new BarkBattleController({ ...DEFAULT_BARK_BATTLE_CONFIG, roundDurationMs: 1, countdownMs: 0 });
controller.startWithMockInput();
controller.tick(1);
controller.submitMockSample(1);
controller.tick(120);
controller.submitMockSample(0.1);
controller.tick(2);
expect(controller.getSnapshot().result).not.toBeNull();
controller.restart();
expect(controller.getSnapshot().phase).toBe('permission');
expect(controller.getSnapshot().player.barkCount).toBe(0);
expect(controller.getSnapshot().energy).toBe(0);
expect(controller.getSnapshot().result).toBeNull();
});
});