You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React钢琴可视化:动画碰撞检测与暂停恢复方案问询

问题描述

我正在用React开发一款带可视化功能的钢琴应用,支持录制和回放乐曲(录制内容包含音符列表及起止时间戳)。当前可视化逻辑是:音符元素从屏幕顶部展开高度,随后下落出屏幕并删除,动画使用fill: 'forwards'。现在遇到两个核心问题:

  1. 希望可视化元素碰撞琴键div时播放对应音效,但当前基于timeout的实现不稳定,需要可靠的碰撞检测方案。
  2. 动画的暂停与恢复功能现有实现不稳定,需要更可靠的实现方式(不考虑保存为视频)。

解决方案

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.27 00:54:59