React钢琴可视化:动画碰撞检测与暂停恢复方案问询
问题描述
我正在用React开发一款带可视化功能的钢琴应用,支持录制和回放乐曲(录制内容包含音符列表及起止时间戳)。当前可视化逻辑是:音符元素从屏幕顶部展开高度,随后下落出屏幕并删除,动画使用fill: 'forwards'。现在遇到两个核心问题:
- 希望可视化元素碰撞琴键div时播放对应音效,但当前基于timeout的实现不稳定,需要可靠的碰撞检测方案。
- 动画的暂停与恢复功能现有实现不稳定,需要更可靠的实现方式(不考虑保存为视频)。
解决方案
1. 可视化元素与琴键的碰撞检测
放弃timeout方案,改用getBoundingClientRect()实时获取元素位置,结合requestAnimationFrame做帧级检测,确保碰撞触发的准确性:
步骤1:给琴键添加ref
在Piano Key组件中添加琴键自身的ref,用于获取位置信息:
const pianoKeyRef = useRef(null); // 绑定到琴键根元素 return ( <div ref={pianoKeyRef} className="piano-key"> {Object.values(playback_visuals)} {/* 其他琴键内容 */} </div> );
步骤2:实现碰撞检测工具函数
添加判断两个元素是否重叠的逻辑:
const checkCollision = (elementA, elementB) => { const rectA = elementA.getBoundingClientRect(); const rectB = elementB.getBoundingClientRect(); // 矩形不重叠的四种情况,取反即为碰撞 return !( rectA.top > rectB.bottom || rectA.bottom < rectB.top || rectA.left > rectB.right || rectA.right < rectB.left ); };
步骤3:启动帧级碰撞检测
修改可视化元素的挂载逻辑,在元素渲染后持续检测碰撞,触发音效:
useEffect(() => { if (pb_visual_mode === 'expand_down') { const currentIndex = pb_counter.current; set_playback_visuals(prev_state => ({ ...prev_state, [currentIndex]: ( <div key={`${currentIndex}`} ref={ref => { playback_visual_refs.current[currentIndex] = ref; if (ref) { // 启动帧检测 const detect = () => { if (checkCollision(ref, pianoKeyRef.current)) { audio.current.play(); } else { requestAnimationFrame(detect); } }; requestAnimationFrame(detect); } }} className='pb-visualizer-instance' ></div> ) })); pb_counter.current += 1; } // 保留其他pb_visual_mode逻辑 }, [pb_visual_mode]);
2. 动画暂停与恢复的可靠实现
利用Web Animations API原生的pause()/play()方法,结合时间差记录调整动画时序,避免错乱:
步骤1:重构动画管理逻辑
将零散的动画实例改为用Map存储,同时记录每个动画的暂停时间:
// 替换原curr_pb_anim ref const animationsRef = useRef(new Map()); // 结构:key=可视化元素索引,value={ anim: Animation实例, pauseTime: 暂停时的时间戳 }
步骤2:修改动画创建逻辑
创建动画时存入Map管理:
useEffect(() => { const currentIndex = pb_counter.current - 1; if (playback_visuals[currentIndex]) { const visualEl = playback_visual_refs.current[currentIndex]; const anim = attribute_animation(visualEl, 'height', '0', '300000px', 1000000); animationsRef.current.set(currentIndex, { anim, pauseTime: null }); } }, [playback_visuals]);
步骤3:实现稳定的暂停/恢复
useEffect(() => { if (pb_visual_mode === 'pause') { // 暂停所有动画并记录暂停时间 animationsRef.current.forEach(({ anim }, index) => { anim.pause(); animationsRef.current.set(index, { ...animationsRef.current.get(index), pauseTime: performance.now() }); }); // 暂停定时器 Object.values(timeouts.current).forEach(timer => timer.pause()); } else if (pb_visual_mode === 'resume') { // 恢复动画,补全暂停时长 animationsRef.current.forEach(({ anim, pauseTime }, index) => { if (pauseTime) { const elapsed = performance.now() - pauseTime; anim.startTime += elapsed; // 调整动画起始时间,抵消暂停的时长 } anim.play(); }); // 恢复定时器 Object.values(timeouts.current).forEach(timer => timer.resume()); } // 保留其他pb_visual_mode逻辑 }, [pb_visual_mode]);
步骤4:优化全局播放时序(Piano Component)
在Piano组件中新增暂停时间记录,恢复时补全全局时间差:
// 新增ref记录暂停开始时间 let sp_pause_time = useRef(null); function pause() { cancelAnimationFrame(sp_anim_frameid.current); sp_pause_time.current = performance.now(); // 记录暂停时刻 // 现有重置pb_visual_mode的逻辑 } function resume() { const pauseDuration = performance.now() - sp_pause_time.current; sp_start_time.current += pauseDuration; // 补全全局时间差 sp_anim_frameid.current = requestAnimationFrame(animate); // 现有重置pb_visual_mode的逻辑 }
内容的提问来源于stack exchange,提问作者The Canadian
相关产品推荐
相关产品推荐

