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,5 @@
import { BarkBattleRuntimeShell } from './games/bark-battle/ui/BarkBattleRuntimeShell';
export default function BarkBattlePlaygroundApp() {
return <BarkBattleRuntimeShell />;
}

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();
});
});

View File

@@ -0,0 +1,30 @@
import type { BarkBattleResult, BarkBattleWinner } from './BarkBattleTypes';
export function decideBarkBattleWinner(
energy: number,
drawThreshold: number,
): BarkBattleWinner {
if (energy > drawThreshold) {
return 'player';
}
if (energy < -drawThreshold) {
return 'opponent';
}
return 'draw';
}
export function buildBarkBattleResult(input: {
energy: number;
drawThreshold: number;
playerBarkCount: number;
opponentBarkCount: number;
}): BarkBattleResult {
const winner = decideBarkBattleWinner(input.energy, input.drawThreshold);
return {
winner,
playerBarkCount: input.playerBarkCount,
opponentBarkCount: input.opponentBarkCount,
finalEnergy: input.energy,
score: Math.max(0, Math.round(input.energy + input.playerBarkCount * 120)),
};
}

View File

@@ -0,0 +1,154 @@
import type { BarkBattleConfig } from '../application/BarkBattleConfig';
import { buildBarkBattleResult } from './BarkBattleScoring';
import type { BarkBattleEvent, BarkBattleSnapshot } from './BarkBattleTypes';
import { advanceEnergy, clampEnergy } from './EnergyTugOfWar';
import { computeOpponentPower } from './OpponentStrategy';
export class BarkBattleSession {
constructor(
private readonly config: BarkBattleConfig,
readonly snapshot: BarkBattleSnapshot,
) {}
startMockRound() {
return new BarkBattleSession(this.config, {
...this.snapshot,
phase: this.config.countdownMs > 0 ? 'countdown' : 'playing',
uiState: this.config.countdownMs > 0 ? 'ready-countdown' : 'playing',
countdownMs: this.config.countdownMs,
remainingMs: this.config.roundDurationMs,
lastEvents: [],
});
}
tick(deltaMs: number) {
if (this.snapshot.phase === 'finished' || this.snapshot.phase === 'unavailable') {
return this.withEvents([]);
}
if (this.snapshot.phase === 'countdown') {
const countdownMs = Math.max(0, this.snapshot.countdownMs - deltaMs);
return new BarkBattleSession(this.config, {
...this.snapshot,
phase: countdownMs <= 0 ? 'playing' : 'countdown',
uiState: countdownMs <= 0 ? 'playing' : 'ready-countdown',
countdownMs,
remainingMs: this.config.roundDurationMs,
lastEvents: [],
});
}
if (this.snapshot.phase !== 'playing') {
return this.withEvents([]);
}
const elapsedMs = this.snapshot.elapsedMs + deltaMs;
const remainingMs = Math.max(0, this.snapshot.remainingMs - deltaMs);
const opponentPower = computeOpponentPower(this.config, elapsedMs);
const energy = advanceEnergy({
energy: this.snapshot.energy,
playerPower: this.snapshot.player.power,
opponentPower,
deltaMs,
balanceFactor: this.config.balanceFactor,
});
const nextSnapshot: BarkBattleSnapshot = {
...this.snapshot,
elapsedMs,
remainingMs,
energy,
opponent: {
...this.snapshot.opponent,
power: opponentPower,
},
player: {
...this.snapshot.player,
power: Math.max(0, this.snapshot.player.power * 0.78),
},
lastEvents: [],
};
if (remainingMs > 0) {
return new BarkBattleSession(this.config, nextSnapshot);
}
const result = buildBarkBattleResult({
energy,
drawThreshold: this.config.drawThreshold,
playerBarkCount: nextSnapshot.player.barkCount,
opponentBarkCount: nextSnapshot.opponent.barkCount,
});
return new BarkBattleSession(this.config, {
...nextSnapshot,
phase: 'finished',
uiState: 'finished',
winner: result.winner,
result,
});
}
applyPlayerBark(event: BarkBattleEvent) {
if (this.snapshot.phase !== 'playing') {
return this.withEvents([]);
}
const playerPower = Math.min(1, Math.max(this.snapshot.player.power, event.peakVolume));
return new BarkBattleSession(this.config, {
...this.snapshot,
energy: clampEnergy(this.snapshot.energy + event.peakVolume * 12),
player: {
barkCount: this.snapshot.player.barkCount + 1,
power: playerPower,
},
lastEvents: [event],
});
}
failMicrophone(reason: BarkBattleSnapshot['errorReason']) {
return new BarkBattleSession(this.config, {
...this.snapshot,
phase: 'unavailable',
uiState: 'microphone-unavailable',
errorReason: reason,
statusMessageKey: reason ? MICROPHONE_STATUS_KEYS[reason] : null,
lastEvents: [],
});
}
private withEvents(lastEvents: BarkBattleEvent[]) {
return new BarkBattleSession(this.config, {
...this.snapshot,
lastEvents,
});
}
}
const MICROPHONE_STATUS_KEYS = {
unsupported: 'microphone-unsupported',
'permission-denied': 'microphone-permission-denied',
'non-secure-context': 'microphone-non-secure-context',
'not-found': 'microphone-not-found',
'not-readable': 'microphone-not-readable',
'audio-context-blocked': 'microphone-audio-context-blocked',
'calibration-timeout': 'microphone-calibration-timeout',
'calibration-sample-unreadable': 'microphone-calibration-sample-unreadable',
unknown: 'microphone-unknown-error',
} as const;
export function createBarkBattleSession(config: BarkBattleConfig) {
return new BarkBattleSession(config, {
phase: 'permission',
uiState: 'permission-ready',
errorReason: null,
statusMessageKey: null,
elapsedMs: 0,
remainingMs: config.roundDurationMs,
countdownMs: config.countdownMs,
energy: 0,
player: { barkCount: 0, power: 0 },
opponent: { barkCount: 0, power: config.opponentBasePower },
winner: null,
result: null,
lastEvents: [],
});
}

View File

@@ -0,0 +1,84 @@
export type BarkBattlePhase =
| 'permission'
| 'calibration'
| 'countdown'
| 'playing'
| 'finished'
| 'unavailable';
export type BarkBattleSide = 'player' | 'opponent';
export type BarkBattleWinner = BarkBattleSide | 'draw' | null;
export type BarkBattleDifficulty = 'easy' | 'normal' | 'hard';
export type BarkBattleUiState =
| 'idle'
| 'permission-ready'
| 'microphone-authorized'
| 'calibrating'
| 'ready-countdown'
| 'playing'
| 'finished'
| 'microphone-unavailable';
export type MicrophoneFailureReason =
| 'unsupported'
| 'permission-denied'
| 'non-secure-context'
| 'not-found'
| 'not-readable'
| 'audio-context-blocked'
| 'calibration-timeout'
| 'calibration-sample-unreadable'
| 'unknown';
export type BarkBattleStatusMessageKey =
| 'microphone-unsupported'
| 'microphone-permission-denied'
| 'microphone-non-secure-context'
| 'microphone-not-found'
| 'microphone-not-readable'
| 'microphone-audio-context-blocked'
| 'microphone-calibration-timeout'
| 'microphone-calibration-sample-unreadable'
| 'microphone-unknown-error';
export type BarkAudioSample = {
atMs: number;
volume: number;
};
export type BarkBattleEvent = {
side: BarkBattleSide;
atMs: number;
peakVolume: number;
durationMs: number;
};
export type BarkBattleParticipantState = {
barkCount: number;
power: number;
};
export type BarkBattleResult = {
winner: BarkBattleWinner;
playerBarkCount: number;
opponentBarkCount: number;
finalEnergy: number;
score: number;
};
export type BarkBattleSnapshot = {
phase: BarkBattlePhase;
uiState: BarkBattleUiState;
errorReason: MicrophoneFailureReason | null;
statusMessageKey: BarkBattleStatusMessageKey | null;
elapsedMs: number;
remainingMs: number;
countdownMs: number;
energy: number;
player: BarkBattleParticipantState;
opponent: BarkBattleParticipantState;
winner: BarkBattleWinner;
result: BarkBattleResult | null;
lastEvents: BarkBattleEvent[];
};

View File

@@ -0,0 +1,69 @@
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,
};
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;
if (!accepted) {
return [];
}
this.lastAcceptedAtMs = activeBark.startMs;
return [
{
side: 'player',
atMs: activeBark.startMs,
peakVolume: activeBark.peakVolume,
durationMs,
},
];
}
}
function clamp01(value: number) {
if (!Number.isFinite(value)) {
return 0;
}
return Math.min(1, Math.max(0, value));
}

View File

@@ -0,0 +1,20 @@
export type AdvanceEnergyInput = {
energy: number;
playerPower: number;
opponentPower: number;
deltaMs: number;
balanceFactor: number;
};
export function advanceEnergy(input: AdvanceEnergyInput) {
const deltaSeconds = Math.max(0, input.deltaMs) / 1000;
const powerDelta = input.playerPower - input.opponentPower;
return clampEnergy(input.energy + powerDelta * input.balanceFactor * deltaSeconds);
}
export function clampEnergy(value: number) {
if (!Number.isFinite(value)) {
return 0;
}
return Math.min(100, Math.max(-100, value));
}

View File

@@ -0,0 +1,6 @@
import type { BarkBattleConfig } from '../application/BarkBattleConfig';
export function computeOpponentPower(config: BarkBattleConfig, elapsedMs: number) {
const pulse = 0.05 * Math.sin(elapsedMs / 480);
return Math.min(1, Math.max(0, config.opponentBasePower + pulse));
}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_BARK_BATTLE_CONFIG } from '../../application/BarkBattleConfig';
import { decideBarkBattleWinner } from '../BarkBattleScoring';
import { createBarkBattleSession } from '../BarkBattleSession';
describe('BarkBattleSession', () => {
it('能从校准完成进入倒计时、playing 并在归零后结算', () => {
let session = createBarkBattleSession({ ...DEFAULT_BARK_BATTLE_CONFIG, roundDurationMs: 1000, countdownMs: 600 });
expect(session.snapshot.phase).toBe('permission');
session = session.startMockRound();
expect(session.snapshot.phase).toBe('countdown');
session = session.tick(600);
expect(session.snapshot.phase).toBe('playing');
expect(session.snapshot.remainingMs).toBe(1000);
session = session.tick(400);
expect(session.snapshot.remainingMs).toBe(600);
session = session.applyPlayerBark({ atMs: 700, peakVolume: 0.9, durationMs: 140, side: 'player' });
expect(session.snapshot.player.barkCount).toBe(1);
expect(session.snapshot.energy).toBeGreaterThan(0);
session = session.tick(600);
expect(session.snapshot.phase).toBe('finished');
expect(session.snapshot.result?.winner).toBe('player');
});
it('finished 后输入不再改变本局叫声计数和能量', () => {
let session = createBarkBattleSession({ ...DEFAULT_BARK_BATTLE_CONFIG, roundDurationMs: 1, countdownMs: 0 }).startMockRound().tick(1);
session = session.tick(1);
const before = session.snapshot;
session = session.applyPlayerBark({ atMs: 200, peakVolume: 1, durationMs: 120, side: 'player' });
expect(session.snapshot.player.barkCount).toBe(before.player.barkCount);
expect(session.snapshot.energy).toBe(before.energy);
});
});
describe('decideBarkBattleWinner', () => {
it('按 drawThreshold 判定玩家胜、对手胜和平局', () => {
expect(decideBarkBattleWinner(16, 12)).toBe('player');
expect(decideBarkBattleWinner(-16, 12)).toBe('opponent');
expect(decideBarkBattleWinner(8, 12)).toBe('draw');
});
});

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_BARK_BATTLE_CONFIG } from '../../application/BarkBattleConfig';
import { BarkDetector } from '../BarkDetector';
describe('BarkDetector', () => {
it('超过阈值且持续时长合规时只计为一次有效叫声', () => {
const detector = new BarkDetector({
threshold: 0.45,
minBarkGapMs: DEFAULT_BARK_BATTLE_CONFIG.minBarkGapMs,
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 });
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ side: 'player', peakVolume: 0.76 });
expect(events[0]?.durationMs).toBe(140);
});
it('持续噪音不会在每个 tick 无限计数', () => {
const detector = new BarkDetector({
threshold: 0.4,
minBarkGapMs: 250,
minBarkDurationMs: 80,
maxBarkDurationMs: 600,
});
const allEvents = [
...detector.acceptSample({ atMs: 0, volume: 0.7 }),
...detector.acceptSample({ atMs: 100, volume: 0.72 }),
...detector.acceptSample({ atMs: 200, volume: 0.73 }),
...detector.acceptSample({ atMs: 300, volume: 0.75 }),
...detector.acceptSample({ atMs: 500, volume: 0.2 }),
];
expect(allEvents).toHaveLength(1);
});
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([]);
detector.acceptSample({ atMs: 500, volume: 0.88 });
expect(detector.acceptSample({ atMs: 620, volume: 0.2 })).toHaveLength(1);
detector.acceptSample({ atMs: 700, volume: 0.9 });
expect(detector.acceptSample({ atMs: 820, volume: 0.2 })).toEqual([]);
});
});

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { advanceEnergy } from '../EnergyTugOfWar';
describe('advanceEnergy', () => {
it('玩家推动力高于对手时能量增加', () => {
expect(advanceEnergy({ energy: 0, playerPower: 0.8, opponentPower: 0.2, deltaMs: 1000, balanceFactor: 40 })).toBeGreaterThan(0);
});
it('对手推动力高于玩家时能量减少', () => {
expect(advanceEnergy({ energy: 0, playerPower: 0.1, opponentPower: 0.7, deltaMs: 1000, balanceFactor: 40 })).toBeLessThan(0);
});
it('能量被限制在 -100 到 100 且双方相等时保持稳定', () => {
expect(advanceEnergy({ energy: 98, playerPower: 1, opponentPower: 0, deltaMs: 2000, balanceFactor: 40 })).toBe(100);
expect(advanceEnergy({ energy: -98, playerPower: 0, opponentPower: 1, deltaMs: 2000, balanceFactor: 40 })).toBe(-100);
expect(advanceEnergy({ energy: 12, playerPower: 0.5, opponentPower: 0.5, deltaMs: 1000, balanceFactor: 40 })).toBeCloseTo(12);
});
});

View File

@@ -0,0 +1,24 @@
import type { MicrophoneFailureReason } from '../domain/BarkBattleTypes';
export function mapGetUserMediaError(error: unknown): MicrophoneFailureReason {
const name = error && typeof error === 'object' && 'name' in error ? String((error as { name?: unknown }).name) : '';
if (name === 'NotAllowedError' || name === 'SecurityError') return 'permission-denied';
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') return 'not-found';
if (name === 'NotReadableError' || name === 'TrackStartError') return 'not-readable';
return 'unknown';
}
export function isMicrophoneApiSupported(windowLike: { isSecureContext?: boolean; navigator?: Navigator | { mediaDevices?: { getUserMedia?: unknown } } }) {
if (windowLike.isSecureContext === false) {
return { ok: false as const, reason: 'non-secure-context' as const };
}
const getUserMedia = windowLike.navigator?.mediaDevices?.getUserMedia;
if (typeof getUserMedia !== 'function') {
return { ok: false as const, reason: 'unsupported' as const };
}
return { ok: true as const, reason: null };
}
export function stopMediaStreamTracks(stream: MediaStream) {
stream.getTracks().forEach((track) => track.stop());
}

View File

@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from 'vitest';
import { isMicrophoneApiSupported, mapGetUserMediaError, stopMediaStreamTracks } from '../BrowserMicrophoneInput';
describe('BrowserMicrophoneInput', () => {
it('区分非安全上下文和不支持 getUserMedia', () => {
expect(isMicrophoneApiSupported({ isSecureContext: false })).toEqual({ ok: false, reason: 'non-secure-context' });
expect(isMicrophoneApiSupported({ isSecureContext: true, navigator: {} })).toEqual({ ok: false, reason: 'unsupported' });
});
it('映射常见 getUserMedia 错误', () => {
expect(mapGetUserMediaError({ name: 'NotAllowedError' })).toBe('permission-denied');
expect(mapGetUserMediaError({ name: 'NotFoundError' })).toBe('not-found');
expect(mapGetUserMediaError({ name: 'NotReadableError' })).toBe('not-readable');
expect(mapGetUserMediaError({ name: 'OtherError' })).toBe('unknown');
});
it('停止 MediaStream 的所有音轨', () => {
const stopA = vi.fn();
const stopB = vi.fn();
stopMediaStreamTracks({ getTracks: () => [{ stop: stopA }, { stop: stopB }] } as unknown as MediaStream);
expect(stopA).toHaveBeenCalledTimes(1);
expect(stopB).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,193 @@
.bark-battle-hud {
min-height: 100svh;
color: #fff7ed;
background: radial-gradient(circle at 50% 15%, rgba(251, 191, 36, 0.35), transparent 28%), linear-gradient(180deg, #1f1147 0%, #521b4f 48%, #130a28 100%);
display: flex;
flex-direction: column;
gap: 18px;
padding: max(18px, env(safe-area-inset-top)) 16px max(18px, env(safe-area-inset-bottom));
box-sizing: border-box;
overflow: hidden;
}
.bark-battle-hud__topline {
display: grid;
gap: 10px;
}
.bark-battle-hud__timer {
justify-self: center;
border-radius: 999px;
padding: 8px 16px;
background: rgba(15, 23, 42, 0.56);
font-weight: 900;
letter-spacing: 0.04em;
}
.bark-battle-energy {
position: relative;
display: flex;
height: 18px;
border: 2px solid rgba(255, 247, 237, 0.78);
border-radius: 999px;
overflow: hidden;
background: rgba(15, 23, 42, 0.48);
}
.bark-battle-energy__side--player { background: linear-gradient(90deg, #f97316, #facc15); }
.bark-battle-energy__side--opponent { background: linear-gradient(90deg, #60a5fa, #a78bfa); }
.bark-battle-arena {
flex: 1;
min-height: 0;
display: grid;
grid-template-rows: 1fr auto 1fr;
place-items: center;
}
.bark-battle-dog {
display: grid;
place-items: center;
gap: 8px;
}
.bark-battle-dog__body {
font-size: clamp(92px, 30vw, 150px);
filter: drop-shadow(0 18px 22px rgba(0, 0, 0, 0.42));
}
.bark-battle-dog--player .bark-battle-dog__body {
transform: rotateY(180deg) translateY(4px);
}
.bark-battle-dog__label,
.bark-battle-vs {
font-weight: 900;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
}
.bark-battle-vs {
border-radius: 999px;
padding: 10px 18px;
background: rgba(255, 255, 255, 0.16);
}
.bark-battle-controls,
.bark-battle-result__stats {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.bark-battle-controls button,
.bark-battle-primary-button {
border: 0;
border-radius: 999px;
padding: 12px 18px;
color: #1f1147;
background: #fff7ed;
font-weight: 900;
}
.bark-battle-primary-button {
background: linear-gradient(135deg, #facc15, #fb7185);
}
.bark-battle-status-card,
.bark-battle-result {
margin: auto;
width: min(92vw, 420px);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 28px;
padding: 24px;
text-align: center;
background: rgba(15, 23, 42, 0.68);
box-shadow: 0 26px 60px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(18px);
}
.bark-battle-result__stats span {
min-width: 84px;
display: grid;
gap: 4px;
}
.bark-battle-result__stats strong {
font-size: 28px;
}
.bark-battle-particles {
position: absolute;
inset: 18% 0 auto;
pointer-events: none;
text-align: center;
font-size: clamp(30px, 10vw, 70px);
font-weight: 950;
letter-spacing: 0.08em;
color: rgba(255, 247, 237, 0.88);
text-shadow: 0 0 18px rgba(250, 204, 21, 0.75);
animation: barkBattleParticlePop 820ms ease-out both;
}
.bark-battle-debug-panel {
position: fixed;
right: 12px;
bottom: max(12px, env(safe-area-inset-bottom));
z-index: 8;
width: min(92vw, 340px);
max-height: 42svh;
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 22px;
padding: 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 header,
.bark-battle-debug-panel label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.bark-battle-debug-panel label {
margin-top: 8px;
font-size: 12px;
}
.bark-battle-debug-panel input {
flex: 1;
}
.bark-battle-debug-panel output {
min-width: 44px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.bark-battle-debug-panel__controls {
display: flex;
gap: 8px;
margin-top: 10px;
}
.bark-battle-debug-panel__controls button {
flex: 1;
border: 0;
border-radius: 999px;
padding: 8px 10px;
color: #1f1147;
background: #fff7ed;
font-weight: 800;
}
@keyframes barkBattleParticlePop {
from { transform: translateY(28px) scale(0.7); opacity: 0; }
42% { opacity: 1; }
to { transform: translateY(-80px) scale(1.14); opacity: 0; }
}

View File

@@ -0,0 +1,91 @@
import './BarkBattleHud.css';
import type { BarkBattleSnapshot } from '../domain/BarkBattleTypes';
type BarkBattleHudProps = {
snapshot: BarkBattleSnapshot;
onStartMicrophone?: () => void;
onMockBark?: () => void;
onMockQuiet?: () => void;
onRestart?: () => void;
};
const failureText = {
unsupported: '当前浏览器不支持麦克风输入',
'permission-denied': '麦克风授权被拒绝',
'non-secure-context': '当前环境无法使用麦克风',
'not-found': '未检测到麦克风',
'not-readable': '麦克风暂时不可读',
'audio-context-blocked': '音频上下文被拦截',
'calibration-timeout': '校准超时',
'calibration-sample-unreadable': '校准样本不可读',
unknown: '麦克风暂时不可用',
};
export function BarkBattleHud({
snapshot,
onStartMicrophone,
onMockBark,
onMockQuiet,
onRestart,
}: BarkBattleHudProps) {
const playerWidth = `${Math.round(((snapshot.energy + 100) / 200) * 100)}%`;
const opponentWidth = `${Math.round(((100 - snapshot.energy) / 200) * 100)}%`;
const isUnavailable = snapshot.phase === 'unavailable';
return (
<section className="bark-battle-hud" aria-label="汪汪声浪大作战">
<header className="bark-battle-hud__topline">
<div className="bark-battle-hud__timer">{(snapshot.remainingMs / 1000).toFixed(1)}s</div>
<div
className="bark-battle-energy"
role="meter"
aria-label="声浪能量条"
aria-valuemin={-100}
aria-valuemax={100}
aria-valuenow={Math.round(snapshot.energy)}
>
<div className="bark-battle-energy__side bark-battle-energy__side--player" data-testid="player-energy-fill" style={{ width: playerWidth }} />
<div className="bark-battle-energy__side bark-battle-energy__side--opponent" data-testid="opponent-energy-fill" style={{ width: opponentWidth }} />
</div>
</header>
{isUnavailable ? (
<div className="bark-battle-status-card">
<h1>{snapshot.errorReason ? failureText[snapshot.errorReason] : '麦克风暂时不可用'}</h1>
{snapshot.errorReason !== 'unsupported' ? (
<button type="button" className="bark-battle-primary-button" onClick={onStartMicrophone}>
{snapshot.errorReason === 'permission-denied' ? '重新授权' : '重试'}
</button>
) : null}
</div>
) : (
<div className="bark-battle-arena" aria-label="竖屏声浪竞技场">
<div className="bark-battle-dog bark-battle-dog--player" aria-label="玩家狗狗背对屏幕">
<span className="bark-battle-dog__body">🐕</span>
<span className="bark-battle-dog__label"> · {snapshot.player.barkCount}</span>
</div>
<div className="bark-battle-vs">VS</div>
<div className="bark-battle-dog bark-battle-dog--opponent" aria-label="对手狗狗面向屏幕">
<span className="bark-battle-dog__body">🐶</span>
<span className="bark-battle-dog__label"> · {snapshot.opponent.barkCount}</span>
</div>
</div>
)}
<footer className="bark-battle-controls">
{snapshot.phase === 'permission' ? (
<button className="bark-battle-primary-button" type="button" onClick={onStartMicrophone}>
</button>
) : null}
<button type="button" onPointerDown={onMockBark} onPointerUp={onMockQuiet} onClick={onMockBark}>
</button>
{snapshot.phase === 'finished' ? (
<button type="button" onClick={onRestart}></button>
) : null}
</footer>
</section>
);
}

View File

@@ -0,0 +1,34 @@
import type { BarkBattleResult } from '../domain/BarkBattleTypes';
type BarkBattleResultPanelProps = {
result: BarkBattleResult;
onRestart: () => void;
};
export function BarkBattleResultPanel({ result, onRestart }: BarkBattleResultPanelProps) {
const title = result.winner === 'player' ? '汪力压制成功' : result.winner === 'opponent' ? '对手声浪更强' : '势均力敌';
return (
<section className="bark-battle-result" role="dialog" aria-label="对战结算">
<p className="bark-battle-result__eyebrow"></p>
<h2>{title}</h2>
<div className="bark-battle-result__stats">
<span>
<strong>{result.playerBarkCount}</strong>
</span>
<span>
<strong>{result.opponentBarkCount}</strong>
</span>
<span>
<strong>{result.score}</strong>
</span>
</div>
<button className="bark-battle-primary-button" type="button" onClick={onRestart}>
</button>
</section>
);
}

View File

@@ -0,0 +1,137 @@
import { useEffect, useRef, useState } from 'react';
import {
type BarkBattleConfig,
DEFAULT_BARK_BATTLE_CONFIG,
} from '../application/BarkBattleConfig';
import { BarkBattleController } from '../application/BarkBattleController';
import { BarkBattleHud } from './BarkBattleHud';
import { BarkBattleResultPanel } from './BarkBattleResultPanel';
type BarkBattleRuntimeShellProps = {
title?: string;
};
const DEBUG_CONFIG_FIELDS: Array<{
key: keyof Pick<
BarkBattleConfig,
| 'roundDurationMs'
| 'countdownMs'
| 'drawThreshold'
| 'barkThreshold'
| 'minBarkGapMs'
| 'balanceFactor'
| 'opponentBasePower'
>;
label: string;
min: number;
max: number;
step: number;
}> = [
{ key: 'roundDurationMs', label: '局长(ms)', min: 1000, max: 60000, step: 1000 },
{ key: 'countdownMs', label: '倒计时(ms)', min: 0, max: 5000, step: 500 },
{ key: 'drawThreshold', label: '平局阈值', min: 0, max: 40, step: 1 },
{ key: 'barkThreshold', label: '叫声阈值', min: 0.1, max: 1, step: 0.05 },
{ key: 'minBarkGapMs', label: '叫声间隔(ms)', min: 100, max: 1200, step: 50 },
{ key: 'balanceFactor', label: '拉锯速度', min: 5, max: 80, step: 1 },
{ key: 'opponentBasePower', label: '对手基础力', min: 0, max: 1, step: 0.05 },
];
export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: BarkBattleRuntimeShellProps) {
const [config, setConfig] = useState(DEFAULT_BARK_BATTLE_CONFIG);
const controllerRef = useRef<BarkBattleController | null>(null);
if (!controllerRef.current) {
controllerRef.current = new BarkBattleController(config);
}
const controller = controllerRef.current;
const [snapshot, setSnapshot] = useState(() => controller.getSnapshot());
const [particleText, setParticleText] = useState('');
const heldRef = useRef(false);
useEffect(() => {
controller.updateConfig(config);
setSnapshot(controller.getSnapshot());
}, [config, controller]);
useEffect(() => {
const timer = window.setInterval(() => {
controller.tick(100);
if (heldRef.current) {
controller.submitMockSample(0.88);
} else {
controller.submitMockSample(0.12);
}
setSnapshot(controller.getSnapshot());
}, 100);
return () => window.clearInterval(timer);
}, [controller]);
const restart = () => {
heldRef.current = false;
controller.restart();
setParticleText('');
setSnapshot(controller.getSnapshot());
};
const startMock = () => {
controller.startWithMockInput();
setSnapshot(controller.getSnapshot());
};
const finishNow = () => {
heldRef.current = false;
controller.finishNow();
setSnapshot(controller.getSnapshot());
};
const bark = () => {
heldRef.current = true;
setParticleText('汪!');
window.setTimeout(() => setParticleText(''), 680);
};
return (
<main className="bark-battle-runtime" aria-label={title}>
<BarkBattleHud
snapshot={snapshot}
onStartMicrophone={startMock}
onMockBark={bark}
onMockQuiet={() => {
heldRef.current = false;
}}
onRestart={restart}
/>
<aside className="bark-battle-debug-panel" aria-label="调试面板">
<header>
<strong></strong>
<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>
{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}
</main>
);
}

View File

@@ -0,0 +1,57 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { BarkBattleSnapshot } from '../../domain/BarkBattleTypes';
import { BarkBattleHud } from '../BarkBattleHud';
function buildSnapshot(overrides: Partial<BarkBattleSnapshot> = {}): BarkBattleSnapshot {
return {
phase: 'playing',
uiState: 'playing',
errorReason: null,
statusMessageKey: null,
elapsedMs: 0,
remainingMs: 12_000,
countdownMs: 0,
energy: 40,
player: { barkCount: 3, power: 0.8 },
opponent: { barkCount: 1, power: 0.25 },
winner: null,
result: null,
lastEvents: [],
...overrides,
};
}
describe('BarkBattleHud', () => {
it('playing 阶段展示竖屏核心元素、倒计时和双方狗狗朝向', () => {
render(<BarkBattleHud snapshot={buildSnapshot()} onMockBark={() => {}} onMockQuiet={() => {}} />);
expect(screen.getByText('12.0s')).toBeTruthy();
expect(screen.getByLabelText('玩家狗狗背对屏幕')).toBeTruthy();
expect(screen.getByLabelText('对手狗狗面向屏幕')).toBeTruthy();
expect(screen.getByLabelText('声浪能量条').getAttribute('aria-valuenow')).toBe('40');
});
it('energy 正负值会改变玩家侧和对手侧占比', () => {
const { rerender } = render(<BarkBattleHud snapshot={buildSnapshot({ energy: 60 })} />);
expect(screen.getByTestId('player-energy-fill').getAttribute('style')).toContain('width: 80%');
rerender(<BarkBattleHud snapshot={buildSnapshot({ energy: -60 })} />);
expect(screen.getByTestId('opponent-energy-fill').getAttribute('style')).toContain('width: 80%');
});
it('unsupported 不展示开始声控按钮permission-denied 展示重试授权入口', () => {
const { rerender } = render(
<BarkBattleHud snapshot={buildSnapshot({ phase: 'unavailable', errorReason: 'unsupported' })} onStartMicrophone={() => {}} />,
);
expect(screen.queryByRole('button', { name: '开始声控' })).toBeNull();
rerender(
<BarkBattleHud snapshot={buildSnapshot({ phase: 'unavailable', errorReason: 'permission-denied' })} onStartMicrophone={() => {}} />,
);
expect(screen.getByRole('button', { name: '重新授权' })).toBeTruthy();
});
});

View File

@@ -0,0 +1,26 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { BarkBattleResultPanel } from '../BarkBattleResultPanel';
describe('BarkBattleResultPanel', () => {
it('展示胜负、叫声次数并支持再来一局', async () => {
const onRestart = vi.fn();
render(
<BarkBattleResultPanel
result={{ winner: 'player', playerBarkCount: 6, opponentBarkCount: 2, finalEnergy: 72, score: 792 }}
onRestart={onRestart}
/>,
);
expect(screen.getByRole('dialog', { name: '对战结算' })).toBeTruthy();
expect(screen.getByText('汪力压制成功')).toBeTruthy();
expect(screen.getByText('6')).toBeTruthy();
await userEvent.click(screen.getByRole('button', { name: '再来一局' }));
expect(onRestart).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,25 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { BarkBattleRuntimeShell } from '../BarkBattleRuntimeShell';
describe('BarkBattleRuntimeShell 调试面板', () => {
it('提供开始、结束、重置流程控制按钮和参数滑杆', async () => {
render(<BarkBattleRuntimeShell />);
expect(screen.getByLabelText('调试面板')).toBeTruthy();
expect(screen.getByRole('button', { name: '开始' })).toBeTruthy();
expect(screen.getByRole('button', { name: '结束' })).toBeTruthy();
expect(screen.getByRole('button', { name: '重置' })).toBeTruthy();
expect(screen.getByLabelText('叫声阈值')).toBeTruthy();
await userEvent.click(screen.getByRole('button', { name: '开始' }));
expect(screen.getByText(/countdown|playing/u)).toBeTruthy();
await userEvent.click(screen.getByRole('button', { name: '结束' }));
expect(screen.getByRole('dialog', { name: '对战结算' })).toBeTruthy();
});
});

View File

@@ -19,6 +19,9 @@ export type AppRouteMatch =
| {
kind: 'match3d-playground';
}
| {
kind: 'bark-battle-playground';
}
| {
kind: 'child-motion-demo';
}
@@ -37,6 +40,7 @@ export type ResolvedAppRoute = {
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
const BigFishPlaygroundApp = lazy(() => import('../BigFishPlaygroundApp')) as AppRouteComponent;
const Match3DPlaygroundApp = lazy(() => import('../Match3DPlaygroundApp')) as AppRouteComponent;
const BarkBattlePlaygroundApp = lazy(() => import('../BarkBattlePlaygroundApp')) as AppRouteComponent;
const PuzzlePlaygroundApp = lazy(() => import('../PuzzlePlaygroundApp')) as AppRouteComponent;
const ChildMotionDemoApp = lazy(() => import('../ChildMotionDemoApp')) as AppRouteComponent;
@@ -65,6 +69,12 @@ export function matchAppRoute(pathname: string): AppRouteMatch {
};
}
if (normalizedPath === '/bark-battle') {
return {
kind: 'bark-battle-playground',
};
}
if (
normalizedPath === '/child-motion-demo' &&
isEdutainmentEntryEnabled()
@@ -109,6 +119,15 @@ export function resolveAppRoute(pathname: string): ResolvedAppRoute {
};
}
if (matchedRoute.kind === 'bark-battle-playground') {
return {
kind: 'bark-battle-playground',
loadingEyebrow: '正在载入汪汪声浪',
loadingText: '正在进入竖屏声浪竞技场...',
Component: BarkBattlePlaygroundApp,
};
}
if (matchedRoute.kind === 'child-motion-demo') {
return {
kind: 'child-motion-demo',