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