ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

漂流瓶动效实战:CSS+SVG波浪动画与贝塞尔抛掷交互

漂流瓶动效实战:CSS+SVG波浪动画与贝塞尔抛掷交互 简介一份模拟漂流瓶交互效果的前端源码适合前端初学者及对趣味交互、事件驱动编程感兴趣的开发者参考。资源通过事件监听、随机捞取逻辑与界面动效还原了投掷、漂流、打捞瓶子的完整流程可帮助理解前端交互设计与随机算法在实际项目中的落地方式。压缩包共39个文件以png图片、js脚本、gif动画、css样式为主另含htm入口文件、说明文档及许可证整体仅321KB。已有557人学习/下载。包内核心脚本、页面结构与样式表分工明确搭配多张漂流瓶、海洋背景等素材图便于读者对照源码逐段分析事件绑定、动画切换和随机选择机制整体目录规整、素材齐全对于想动手实现类似社交趣味功能的前端开发者颇具参考价值。1. 漂流瓶效果到底难在哪里先定义它是个什么效果同一屏里放十来个玻璃瓶各自在波浪上慢悠悠地晃点一下就抛物线飞出去落到另一片海面上继续漂。真正做过的人会告诉你难的不是“画出瓶子”而是让它在停止不动时看起来还在“活”着——波浪不重样、瓶子不穿帮、帧率不掉。这个标题里的源码如果值得参考参考价值通常集中在三件事层叠的波形动画、瓶子自身的悬浮写实感、加上一条足够顺手的抛掷轨迹算法。这篇文章就按这三件事展开。第一件事讲清楚怎么写一个能原地循环的波浪层第二件把瓶子的运动拆成平移、起伏和倾斜三个独立信号第三件把点击到落水的完整交互链补上包含角度插值和落点微调。最后再给一个不需要装任何面板就能跑的验证方法在浏览器里直接看帧耗时和像素对齐。适合正在做移动端 H5 活动页、Web 端温情类叙事页面或者单纯想把位移动画做得不“簧片味”的人。2. 海面与瓶身把主视觉拆成可叠加的分层2.1 海面先做两层渐变底色加上 SVG noise 暗纹纯靠 CSS 把海面“画”出来的常见做法是上下叠两层底层用linear-gradient做出水色由浅到深的过渡上层叠加一张带噪声的 SVG 滤镜用来打断大色块上过于干净的塑料感。SVG 用内联svg加filter标签不产生额外图片请求代码量也小。svg width0 height0 styleposition:absolute filter idseaNoise feTurbulence typefractalNoise baseFrequency0.012 0.03 numOctaves3 resultnoise / feColorMatrix typematrix values0 0 0 0 0.25 0 0 0 0 0.35 0 0 0 0 0.55 0 0 0 0.25 0 innoise resultcoloredNoise / feBlend inSourceGraphic in2coloredNoise modemultiply / /filter /svgbaseFrequency沿 x 轴取 0.012、沿 y 轴取 0.03让噪点横向长、纵向短比较贴近浪纹的直觉numOctaves3叠加三层噪声细节。feBlend用multiply混合后暗纹会附着在底色上而不是直接用浅色覆盖。把这一层放在海面容器里作为背景波浪单元放在它上方。这里有个容易出错的地方filter 作用在整块背景上时如果直接设给最外层容器里面的动画元素也会继承滤镜计算帧率立刻掉一截。正确做法是让滤镜容器和波浪容器保持同级各管各的合成。2.2 瓶身用 SVG 拆层瓶体、软木塞、标签与水体漂流瓶在静止状态下最好看的表现形式是把瓶身拆成几个独立子层在动画循环里给它们分配差异很小的运动参数模拟玻璃折射和水体晃动。子层结构运动分量瓶体轮廓单条 path带 2px 描边整体位移、整体旋转软木塞顶部 rect 圆角只在 y 轴做 0.5px 级抖动标签中段圆角矩形浅色跟随瓶体旋转量减半瓶内水体椭圆 path透明度 0.45椭圆 ry 随波高周期变化g idbottle path idglass dM20 10 Q20 4 26 4 L54 4 Q60 4 60 10 L58 66 Q58 72 52 72 L28 72 Q22 72 22 66 Z fillrgba(180,220,255,0.14) strokergba(255,255,255,0.6) stroke-width1.6 / rect idcork x24 y0 width32 height8 rx3 fill#b08968 / rect idlabel x25 y36 width30 height16 rx4 fillrgba(255,250,235,0.85) / ellipse idinnerWater cx40 cy62 rx14 ry6 fillrgba(120,190,220,0.55) / /ginnerWater的ry在帧循环里按正弦函数变化数值范围控制在 4 到 9 之间模拟瓶子里面水随波浪晃动。玻璃层单独放大fill-opacity的变化幅度会产生瓶身通透度随视角略变的错觉——效果不算明显但比完全静止的纯色瓶身要耐看。2.3 分层渲染的取舍原则做这类视觉时最容易踩的方向性错误是试图用大量 DOM 节点去堆水体粒子。常见的可靠方案是静态形状用 SVG 或 CSS 剪裁路径动态水体只保留一个椭圆节点运动量交给 transform 属性。JS 里用Array.from缓存节点引用避免每帧执行 querySelector。对于不同瓶子的深浅和水量可以在生成瓶子时改几个 path 参数。比如玻璃描边透明度取 0.35 到 0.7 之间内水体透明度与描边透明度反向变化这样整套瓶子放到同一片海域里时视觉上不会像同一个模子印出来。每帧更新只发生在 transform 和少量 attribute 上保留一层合成空间给浏览器做异步渲染。3. 让漂流瓶“漂”起来正弦波叠加与异步相位3.1 为什么不是随机抖动而是正弦叠加很多初版实现直接给瓶子一个translateY随机值效果是瓶子上蹿下跳没有海浪托着走的感觉。海面浮体的运动在低频区间核心是三个不同周期的正弦波叠加频率比大约取 1 : 1.7 : 0.32。第一个波决定主浮动节奏第二个波制造上下浮动的错落感第三个长波让瓶子在几秒内缓慢横移打破原地打转的僵硬感。3.2 三个输入一个输出时间、相位和波幅参数每个瓶子的运动状态只依赖三个输入全局时间戳、瓶子自身的随机相位、三个波的振幅系数。相位差的取值范围应该覆盖整个2π保证任何两个瓶子在同一个瞬间都不完全同步。代码里每个瓶子保存一组参数对象形态如下const bottlePhases bottles.map((_, index) ({ // 三个正弦波的初始相位让每个瓶子的节奏错开 phase1: (Math.PI * 2 * index) / bottles.length, phase2: Math.random() * Math.PI * 2, // 中间那个波的频率略不等比晃动才不规律 freqRatio: 1.6 Math.random() * 0.2, // 横向漂移速度配合长周期波让瓶子不原地打转 driftSpeed: 0.08 Math.random() * 0.06, }));freqRatio取值在 1.6 到 1.8 之间时第二个波和主波形成的合运动看起来最像浮体在海浪上的摆动。取值接近 2 会形成有规律的花样曲线反而容易察觉动画在重复。3.3 帧循环里只算位移和旋转渲染循环只做一件事把时间t_ms换算成角度代入三个正弦函数算出 y 偏移、x 偏移和旋转角然后拼成 transform 一次赋值。不要拆成多次 transform 调用浏览器每改一次 transform 都可能触发样式重算。function updateBottle(bottle, params, now) { // 换算成周期约为 5.6s 的主波角度 const t now * 0.001; const a1 Math.sin(t * 2.0 params.phase1); // 第二波频率略高用来做小幅度扰动 const a2 Math.sin(t * 2.0 * params.freqRatio params.phase2); // 长周期横移约 22 秒一个来回 const a3 Math.sin(t * 0.28 params.phase1 * 1.7); const translateY a1 * 8 a2 * 3; // 起伏主导 const translateX a3 * 14 a2 * 1.5; // 横向漂移 const rotate (a1 a2) * 3.2; // 倾角随起伏联动 bottle.style.transform translate3d(${translateX.toFixed(2)}px, ${translateY.toFixed(2)}px, 0) rotate(${rotate.toFixed(2)}deg); }旋转角和 y 偏移共用a1 a2瓶子浪起时同步朝一侧倾斜浪落时往反方向回摆。两套运动脱开的话瓶子的动态看起来会像“生硬的平移后补了一个旋转”。副波振幅定为 3px倾斜量里副波参与度与主波一致视觉上会有细碎的颠簸。帧循环统一放在一个requestAnimationFrame回调里一次循环处理所有瓶子避免为每个瓶子单独占用一个 rAF 实例。十几个瓶子的布局计算量远小于单独起循环的调度开销。3.4 transform-only 的优化价值这三种写法都推荐按优先级使用能用transform就不要改top/left后者会触发布局。瓶身倾斜用rotate不要用 SVG 的transformrotate(...)属性attribute 更新走不了合成器加速。translate3d在移动端某些 WebView 上能强制开启 GPU 合成但不要滥用。现代浏览器里translate和translate3d没有显著差异保持写法统一更重要。4. 抛掷与回收一条贝塞尔轨迹的完整事件链4.1 从点击到落水的几个状态切换漂流瓶的交互可以拆成五个状态待机浮漂、拖拽悬停、飞行中、落水回弹、回到待机。状态机不复杂麻烦在于状态切换时不能让运动参数发生跳变。事件链通常这样做pointerdown时记录点击点与瓶心的偏移同时停止该瓶子的浮动循环更新。pointermove阶段更新一个虚拟的拖拽跟随点瓶子只做位置跟随不做旋转。pointerup时以当前位置为起点、落点为终点生成飞行轨迹。飞行结束后执行落水回弹回弹动画结束再重新接入第 3 章的波浪循环。4.2 两点一线不够需要按抛出方向决定弧高从 A 点飞向 B 点如果用直线插入位置瓶子像被瞬移过去。现实中瓶子是抛出去的需要用一条二次贝塞尔曲线控制点高度由抛出距离决定。function calcThrowPath(start, end) { const distance Math.hypot(end.x - start.x, end.y - start.y); // 抛出距离 1/4 的弧高落点越远弧线越高 const peak distance * 0.25; const mid { x: (start.x end.x) / 2, y: Math.min(start.y, end.y) - peak, }; // 将 t 映射为二次贝塞尔上的坐标 return (t) { const u 1 - t; return { x: u * u * start.x 2 * u * t * mid.x t * t * end.x, y: u * u * start.y 2 * u * t * mid.y t * t * end.y, }; }; }peak取值 0.25 倍距离是视觉上比较从容的高度小于 0.12 会像贴地滑行大于 0.4 会有明显失重感。飞行时间按距离等比例缩放固定速率的做法在长距离下会显得拖沓。function animateThrow(bottle, pathFn, duration) { const startTime performance.now(); return new Promise((resolve) { function step(now) { let t (now - startTime) / duration; // 用 easeOutCubic 让瓶子离手快、落水慢 const eased 1 - Math.pow(1 - t, 3); const pos pathFn(eased); bottle.style.left pos.x px; bottle.style.top pos.y px; // 飞行时瓶子角度随速度方向变化这里简化为随 t 线性转半圈 bottle.style.transform rotate(${120 * eased}deg); t 1 ? requestAnimationFrame(step) : resolve(); } requestAnimationFrame(step); }); }飞行过程中把transform的 rotate 从 0 线性增加到 120 度瓶子多半圈掉进海里这和落水状态的角度衔接比较自然。不能用固定第一次旋转就完事瓶子轨迹的终点方向不同入水角度应该跟随当前位置的切线方向变化。4.3 落水回弹两件事压扁感与水位线常见的落水效果是在终点先做一次极小幅度旋转回归再配合一个 scaleY 压缩和恢复过程。压缩幅度控制在 0.86 到 0.94 之间持续 120ms恢复 240ms。这个压缩不要做得太夸张否则看起来像瓶子拍在石头上不像入水。bottle.animate( [ { transform: rotate(120deg) scale(1, 1), offset: 0 }, { transform: rotate(78deg) scale(1, 0.9), offset: 0.25 }, { transform: rotate(64deg) scale(1, 1), offset: 1 }, ], { duration: 360, easing: cubic-bezier(0.25, 0.8, 0.3, 1) } );旋转收尾角度不要设为零瓶子入水后的姿态应该带一点和航行方向相关的倾斜。比如从左往右抛落水后最终静止角在 8 到 12 度之间会显得水流有方向。4.4 边界条件抛向边缘和快速连续操作两个常见边界问题值得写进实现里落点靠近容器边缘时终点应该向内收缩一段距离避免瓶子半身露在海洋背景外。实际终点用end.x - Math.cos(endAngle) * halfBottleWidth收缩半个瓶宽。连续快速点击会把一个瓶子打出多条飞行动画链旧的回调没结束时新的已经覆盖了 transform。用 AbortController 或简单的动画序号自增判断来丢弃旧回调比每次cancelAnimationFrame更省事。let throwSeq 0; async function handleThrow(bottle, start, end) { const seq throwSeq; const duration Math.min(900, Math.max(450, Math.hypot(end.x - start.x, end.y - start.y) * 0.9)); await animateThrow(bottle, calcThrowPath(start, end), duration); // 只有最后一次操作的动画才能执行入水回弹 if (seq throwSeq) { await splash(bottle); } }入水回弹放在序号校验后面旧动画就算晚到也不会产生叠加抖动。visual 表现上瓶子掉入水中后就地停顿约一帧再接入波浪动画这一帧的静止很关键接不好会把“刚着陆”的质感丢掉。5. 验证与收尾用 DevTools 的 Performance 面板盯三个硬指标漂流瓶这套效果做完最容易出的问题不是“不动”而是“动起来后把页面拖到掉帧”。验证方法不需要引入额外工具Chrome DevTools 的 Performance 面板足够。录制一段 5 秒动画看三个数据帧率是否稳定在 50fps 以上Scripting 时间占比是否超过 12ms/帧Painting 有没有随着瓶子移动出现明显峰值。符合条件再去看渲染细节——在帧列表里点开任意一帧确认Layout记录不存在这说明 transform-only 生效了。掉帧时先用排除法缩小范围通常是这几类原因滤镜尺寸过大瓶子数量超过 20 个但对每个都执行了独立 shadow把 SVG 的filter挂在了动画元素的父级上。借用 Performance 面板里看占用高的条目直接按函数名定位很有效。收尾阶段还有个容易忽略的细节页面失焦时用document.visibilitychange暂停requestAnimationFrame循环恢复时刷新起点时间戳再继续。否则标签页在后台运行一段时间再切回来动画会表现为一段快速回放。时间戳刷新只要在恢复函数里用performance.now()重新记录基准值即可代码改动不超过十行。本文还有配套的精品资源点击获取
返回列表