feat: complete bark battle draft publish flow
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
.bark-battle-hud {
|
||||
position: relative;
|
||||
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%);
|
||||
@@ -10,7 +11,18 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bark-battle-hud__background-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.bark-battle-hud__topline {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -38,6 +50,8 @@
|
||||
.bark-battle-energy__side--opponent { background: linear-gradient(90deg, #60a5fa, #a78bfa); }
|
||||
|
||||
.bark-battle-arena {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
@@ -54,6 +68,10 @@
|
||||
}
|
||||
|
||||
.bark-battle-dog__body {
|
||||
display: grid;
|
||||
width: clamp(112px, 34vw, 170px);
|
||||
aspect-ratio: 1;
|
||||
place-items: center;
|
||||
font-size: clamp(92px, 30vw, 150px);
|
||||
filter: drop-shadow(0 18px 22px rgba(0, 0, 0, 0.42));
|
||||
}
|
||||
@@ -62,6 +80,12 @@
|
||||
transform: rotateY(180deg) translateY(4px);
|
||||
}
|
||||
|
||||
.bark-battle-dog__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.bark-battle-dog__label,
|
||||
.bark-battle-dog__burst,
|
||||
.bark-battle-vs {
|
||||
@@ -94,6 +118,8 @@
|
||||
|
||||
.bark-battle-controls,
|
||||
.bark-battle-result__stats {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import './BarkBattleHud.css';
|
||||
|
||||
import { ResolvedAssetImage } from '../../../components/ResolvedAssetImage';
|
||||
import type { BarkBattleSnapshot } from '../domain/BarkBattleTypes';
|
||||
|
||||
type BarkBattleHudProps = {
|
||||
@@ -10,6 +11,9 @@ type BarkBattleHudProps = {
|
||||
onMockBark?: () => void;
|
||||
onMockQuiet?: () => void;
|
||||
onRestart?: () => void;
|
||||
playerCharacterImageSrc?: string | null;
|
||||
opponentCharacterImageSrc?: string | null;
|
||||
uiBackgroundImageSrc?: string | null;
|
||||
};
|
||||
|
||||
const failureText = {
|
||||
@@ -32,6 +36,9 @@ export function BarkBattleHud({
|
||||
onMockBark,
|
||||
onMockQuiet,
|
||||
onRestart,
|
||||
playerCharacterImageSrc,
|
||||
opponentCharacterImageSrc,
|
||||
uiBackgroundImageSrc,
|
||||
}: BarkBattleHudProps) {
|
||||
const playerWidth = `${Math.round(((snapshot.energy + 100) / 200) * 100)}%`;
|
||||
const opponentWidth = `${Math.round(((100 - snapshot.energy) / 200) * 100)}%`;
|
||||
@@ -39,6 +46,14 @@ export function BarkBattleHud({
|
||||
|
||||
return (
|
||||
<section className="bark-battle-hud" aria-label="汪汪声浪大作战">
|
||||
{uiBackgroundImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={uiBackgroundImageSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="bark-battle-hud__background-image"
|
||||
/>
|
||||
) : null}
|
||||
<header className="bark-battle-hud__topline">
|
||||
<div className="bark-battle-hud__timer">{(snapshot.remainingMs / 1000).toFixed(1)}s</div>
|
||||
<div
|
||||
@@ -65,17 +80,37 @@ export function BarkBattleHud({
|
||||
</div>
|
||||
) : (
|
||||
<div className="bark-battle-arena" aria-label="竖屏声浪竞技场">
|
||||
<div key={`player-${playerPulseKey}`} className="bark-battle-dog bark-battle-dog--player" aria-label="玩家狗狗背对屏幕">
|
||||
<span className="bark-battle-dog__burst" aria-hidden="true">汪</span>
|
||||
<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 key={`opponent-${opponentPulseKey}`} className="bark-battle-dog bark-battle-dog--opponent" aria-label="对手狗狗面向屏幕">
|
||||
<span className="bark-battle-dog__burst" aria-hidden="true">反击</span>
|
||||
<span className="bark-battle-dog__body">🐶</span>
|
||||
<span className="bark-battle-dog__body">
|
||||
{opponentCharacterImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={opponentCharacterImageSrc}
|
||||
alt=""
|
||||
className="bark-battle-dog__image"
|
||||
/>
|
||||
) : (
|
||||
'🐶'
|
||||
)}
|
||||
</span>
|
||||
<span className="bark-battle-dog__label">对手 · {snapshot.opponent.barkCount}</span>
|
||||
</div>
|
||||
<div className="bark-battle-vs">VS</div>
|
||||
<div key={`player-${playerPulseKey}`} className="bark-battle-dog bark-battle-dog--player" aria-label="玩家狗狗背对屏幕">
|
||||
<span className="bark-battle-dog__burst" aria-hidden="true">汪</span>
|
||||
<span className="bark-battle-dog__body">
|
||||
{playerCharacterImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={playerCharacterImageSrc}
|
||||
alt=""
|
||||
className="bark-battle-dog__image"
|
||||
/>
|
||||
) : (
|
||||
'🐕'
|
||||
)}
|
||||
</span>
|
||||
<span className="bark-battle-dog__label">你 · {snapshot.player.barkCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { BarkBattlePublishedConfig } from '../../../../packages/shared/src/contracts/barkBattle';
|
||||
import { useResolvedAssetReadUrl } from '../../../hooks/useResolvedAssetReadUrl';
|
||||
import {
|
||||
type BarkBattleConfig,
|
||||
DEFAULT_BARK_BATTLE_CONFIG,
|
||||
@@ -113,11 +114,25 @@ export function BarkBattleRuntimeShell({
|
||||
const [playerPulseKey, setPlayerPulseKey] = useState(0);
|
||||
const [opponentPulseKey, setOpponentPulseKey] = useState(0);
|
||||
const [debugEvents, setDebugEvents] = useState<DebugEvent[]>([]);
|
||||
const barkAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const heldRef = useRef(false);
|
||||
const lastPlayerBarkCountRef = useRef(0);
|
||||
const lastOpponentPowerRef = useRef(0);
|
||||
const debugEventIdRef = useRef(0);
|
||||
const microphoneSamplerRef = useRef<BrowserMicrophoneSampler | null>(null);
|
||||
const replacementConfig = publishedConfig ?? null;
|
||||
const { resolvedUrl: resolvedBarkSoundSrc } = useResolvedAssetReadUrl(
|
||||
replacementConfig?.barkSoundSrc ?? null,
|
||||
);
|
||||
|
||||
const playBarkSound = useCallback(() => {
|
||||
const audio = barkAudioRef.current;
|
||||
if (!audio || !resolvedBarkSoundSrc) {
|
||||
return;
|
||||
}
|
||||
audio.currentTime = 0;
|
||||
void audio.play().catch(() => {});
|
||||
}, [resolvedBarkSoundSrc]);
|
||||
|
||||
const appendDebugEvent = useCallback((text: string) => {
|
||||
debugEventIdRef.current += 1;
|
||||
@@ -129,16 +144,18 @@ export function BarkBattleRuntimeShell({
|
||||
const nextSnapshot = controller.getSnapshot();
|
||||
if (nextSnapshot.player.barkCount > lastPlayerBarkCountRef.current) {
|
||||
setPlayerPulseKey((current) => current + 1);
|
||||
playBarkSound();
|
||||
appendDebugEvent(`玩家叫声触发 #${nextSnapshot.player.barkCount} · 能量 ${Math.round(nextSnapshot.energy)}`);
|
||||
}
|
||||
if (nextSnapshot.phase === 'playing' && Math.abs(nextSnapshot.opponent.power - lastOpponentPowerRef.current) >= 0.08) {
|
||||
setOpponentPulseKey((current) => current + 1);
|
||||
playBarkSound();
|
||||
appendDebugEvent(`对手反击强度 ${(nextSnapshot.opponent.power * 100).toFixed(0)}%`);
|
||||
}
|
||||
lastPlayerBarkCountRef.current = nextSnapshot.player.barkCount;
|
||||
lastOpponentPowerRef.current = nextSnapshot.opponent.power;
|
||||
setSnapshot(nextSnapshot);
|
||||
}, [appendDebugEvent, controller]);
|
||||
}, [appendDebugEvent, controller, playBarkSound]);
|
||||
|
||||
const stopMicrophone = useCallback(() => {
|
||||
microphoneSamplerRef.current?.stop();
|
||||
@@ -237,10 +254,16 @@ export function BarkBattleRuntimeShell({
|
||||
|
||||
return (
|
||||
<main className="bark-battle-runtime" aria-label={title}>
|
||||
{resolvedBarkSoundSrc ? (
|
||||
<audio ref={barkAudioRef} src={resolvedBarkSoundSrc} preload="auto" />
|
||||
) : null}
|
||||
<BarkBattleHud
|
||||
snapshot={snapshot}
|
||||
playerPulseKey={playerPulseKey}
|
||||
opponentPulseKey={opponentPulseKey}
|
||||
playerCharacterImageSrc={replacementConfig?.playerCharacterImageSrc}
|
||||
opponentCharacterImageSrc={replacementConfig?.opponentCharacterImageSrc}
|
||||
uiBackgroundImageSrc={replacementConfig?.uiBackgroundImageSrc}
|
||||
onStartMicrophone={startMicrophone}
|
||||
onMockBark={bark}
|
||||
onMockQuiet={() => {
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { BarkBattleSnapshot } from '../../domain/BarkBattleTypes';
|
||||
import { BarkBattleHud } from '../BarkBattleHud';
|
||||
|
||||
vi.mock('../../../../components/ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
...props
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
}) => <img src={src ?? ''} alt={alt ?? ''} {...props} />,
|
||||
}));
|
||||
|
||||
function buildSnapshot(overrides: Partial<BarkBattleSnapshot> = {}): BarkBattleSnapshot {
|
||||
return {
|
||||
phase: 'playing',
|
||||
@@ -33,6 +44,8 @@ describe('BarkBattleHud', () => {
|
||||
expect(screen.getByLabelText('玩家狗狗背对屏幕')).toBeTruthy();
|
||||
expect(screen.getByLabelText('对手狗狗面向屏幕')).toBeTruthy();
|
||||
expect(screen.getByLabelText('声浪能量条').getAttribute('aria-valuenow')).toBe('40');
|
||||
const arenaText = screen.getByLabelText('竖屏声浪竞技场').textContent ?? '';
|
||||
expect(arenaText.indexOf('对手 · 1')).toBeLessThan(arenaText.indexOf('你 · 3'));
|
||||
});
|
||||
|
||||
it('energy 正负值会改变玩家侧和对手侧占比', () => {
|
||||
@@ -54,4 +67,19 @@ describe('BarkBattleHud', () => {
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '重新授权' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('展示自定义角色形象和 UI 背景', () => {
|
||||
render(
|
||||
<BarkBattleHud
|
||||
snapshot={buildSnapshot()}
|
||||
playerCharacterImageSrc="/generated-bark-battle/player.png"
|
||||
opponentCharacterImageSrc="https://example.test/opponent.png"
|
||||
uiBackgroundImageSrc="/generated-bark-battle/ui.png"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('img[src="/generated-bark-battle/player.png"]')).toBeTruthy();
|
||||
expect(document.querySelector('img[src="https://example.test/opponent.png"]')).toBeTruthy();
|
||||
expect(document.querySelector('img[src="/generated-bark-battle/ui.png"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,60 @@
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BarkBattleRuntimeShell } from '../BarkBattleRuntimeShell';
|
||||
|
||||
vi.mock('../../../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: (source: string | null | undefined) => ({
|
||||
resolvedUrl: source ?? '',
|
||||
isResolving: false,
|
||||
shouldResolve: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../components/ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
...props
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
}) => <img src={src ?? ''} alt={alt ?? ''} {...props} />,
|
||||
}));
|
||||
|
||||
describe('BarkBattleRuntimeShell 调试面板', () => {
|
||||
it('从发布配置加载自定义狗叫音效资源', () => {
|
||||
render(
|
||||
<BarkBattleRuntimeShell
|
||||
publishedConfig={{
|
||||
workId: 'work-bark-1',
|
||||
draftId: 'draft-bark-1',
|
||||
configVersion: 2,
|
||||
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||
playTypeId: 'bark-battle',
|
||||
title: '周末狗狗杯',
|
||||
themePreset: 'neon-park',
|
||||
playerDogSkinPreset: 'shiba',
|
||||
opponentDogSkinPreset: 'husky',
|
||||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||||
uiBackgroundImageSrc: '/generated-bark-battle/ui.png',
|
||||
barkSoundSrc: '/generated-bark-battle/bark.mp3',
|
||||
difficultyPreset: 'hard',
|
||||
leaderboardEnabled: true,
|
||||
updatedAt: '2026-05-13T03:00:00.000Z',
|
||||
publishedAt: '2026-05-13T03:00:00.000Z',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('audio[src="/generated-bark-battle/bark.mp3"]')).toBeTruthy();
|
||||
expect(document.querySelector('img[src="/generated-bark-battle/player.png"]')).toBeTruthy();
|
||||
expect(document.querySelector('img[src="/generated-bark-battle/ui.png"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('提供开始、结束、重置流程控制按钮和参数滑杆', async () => {
|
||||
render(<BarkBattleRuntimeShell />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user