news 2026/9/7 9:40:06

赛车开奖动画实战:基于原生JS与CSS的状态机驱动实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
赛车开奖动画实战:基于原生JS与CSS的状态机驱动实现

简介:赛车开奖动画源码是一套基于HTML5、CSS和JavaScript及jQuery实现的互动式赛车开奖展示程序,适合前端开发者、游戏爱好者或需要搭建趣味抽奖场景的运营人员学习与二次开发。资源以北京赛车为视觉主题,通过精致的PNG/GIF素材与CSS动画模拟赛车飞驰、冲线、排名展示等动态效果,同时支持调用外部随机数接口,确保开奖结果的公正性与不可预测性。压缩包共40个文件,包含主HTML页面、2个CSS样式文件、2个JS逻辑脚本(含jQuery库)、30张PNG、4张JPG及1张GIF图片,体积仅789KB,结构清爽易上手。目前已有744人浏览学习。通过整套源码,开发者既能掌握前端动画原理、CSS过渡、JavaScript事件处理和API通信等核心技能,也能轻松调整赛车样式、赛道布局与动画速度,快速移植到活动抽奖、游戏Demo等实际场景中。 我一直觉得,前端动画里最难缠的不是那些炫酷的粒子特效,而是“业务含义明确的时序动画”。最近做了一个类似“赛车开奖动画”的小项目,说白了就是几辆赛车在屏幕上冲刺,最终名次对应后端返回的获奖结果。需求本身不复杂,但真正动手之后才发现:难点全在细节里——车怎么起跑、怎么停下、名次怎么和前端的接口数据严格对齐、直播场景下怎么保证节奏不出错。这篇文章就把我完整实现这套动画的源码思路、核心代码和踩坑记录都整理出来,给同样在做赛事播报、活动大屏、抽奖揭晓动画的朋友做个参考。

1. 项目概述与整体设计思路

1.1 这个项目到底要解决什么问题

先把这个动画的使用场景说清楚。它大概率不是普通页面上的装饰动画,而是用在“结果揭晓”这种关键环节:活动大屏上,主持人一声令下,六辆赛车同时出发,几秒之后分别停在不同的名次区域,观众通过肉眼看到谁先到谁后到,然后主持人再逐一宣布获奖者。

这类场景有非常硬的业务要求:

  • 结果必须由数据驱动。后端返回什么名次,车就必须停在对应的位置,不允许随机、不允许偏差。
  • 节奏要可控。整个过程不能太快(几秒就结束会没气氛),也不能太慢(直播或线下活动会冷场),通常控制在5到10秒。
  • 要能重播或跳过。彩排、卡顿、口误时,要么一键重跑,要么直接跳过动画出结果。

也就是说,表面上看是个动画,实际上是一个“数据到动画的映射系统”。这也就是为什么我不建议你在网上随便找几个轮子拼凑——市面上的Canvas动画库、DOM动画库很多,但几乎没有一个专门解决“名次揭晓”这种业务语义的组件,最后还是要自己封装。

1.2 技术选型:为什么坚持用原生 JS + CSS

我最早想过用Canvas,也考虑过直接上GSAP这种专业动画库。但权衡后选了原生 JavaScript + CSS3 动画来写核心部分,理由有三个。

第一,这套动画的实体元素(赛车、赛道、终点线)都是简单的矩形和渐变,用DOM节点配合CSS就能画得很干净,没必要上Canvas重绘。Canvas适合粒子、拖尾、大量物体运动的场景,而这里同时最多也就6到8辆车,DOM完全扛得住。

第二,CSS动画的性能在现代浏览器里非常可靠,尤其是用transform属性驱动位移时,完全不占用主线程,低端安卓机上跑也很稳。

第三,也是最重要的一点——结果揭晓动画需要和业务逻辑深度耦合。用原生代码写状态机和管理函数,我能精确控制每一辆车在某个时间点处于什么状态,出了问题也更容易排查。GSAP虽然方便,但对于这种简单的线性位移,反而有点杀鸡用牛刀。

最终的架构拆成三层:

  • 数据层:接收后端传来的排名数组,例如[3, 1, 4, 2, 6, 5],代表6号车、1号车等各自对应的最终名次。
  • 调度层:负责启停动画、控制出发间隔、处理重播和跳过。
  • 渲染层:DOM节点 + CSS动画,根据调度层给出的目标位移和时间参数完成视觉表现。

2. 核心细节解析:状态机、缓动曲线与坐标映射

2.1 动画状态机:一切都要可控

动画一旦开始,就要进入严格的受控状态。我为整套流程定义了三个状态:

const STATE = { IDLE: 'idle', // 待命 PLAYING: 'playing', // 赛跑中 FINISHED: 'finished' // 全部抵达 };

为什么要单独维护状态?因为用户可能在任何时刻点击“重播”或“直接查看结果”。如果没有状态机,事件回调之间会互相干扰,比如“第一辆车已经停下来了,用户又点了一次重播,结果第二辆车的定时器还在跑”,时间线就会乱套。所有的控制函数(开始、重置、跳过)在执行前都要先检查当前状态,只有合法状态才能触发对应操作。

2.2 缓动曲线不是随便选的

赛车动画最容易翻车的地方就是“看起来假”。很多初学者让车用线性运动从头冲到尾,结果就是机器人平移,毫无赛车的冲刺感和仪式感。

真实的赛车加速过程大致是:起步瞬间有顿挫,然后迅速拉升速度,接近终点时不是均匀减速,而是最后猛地压线。我用CSS的transition配合cubic-bezier来模拟这个感觉:

.car { /* 过渡曲线:前段极快,后段平缓,带一点回弹感 */ transition: transform 4.5s cubic-bezier(0.15, 0.85, 0.25, 1); }

cubic-bezier(0.15, 0.85, 0.25, 1)这条曲线的特点是:动画开始后速度迅速拉到最高,然后慢慢收尾,等快接近目标时再平滑归零。实测下来比默认的ease曲线更有冲刺感,特别是最后几辆车依次压线的时候,那种“咬得很紧然后被拉开”的视觉张力非常明显。

如果想让第一名的车有“冲线惯性”(最后多冲出去一截再退回来),可以再加overshoot效果的曲线,比如:

.car.first { transition: transform 5s cubic-bezier(0.2, 0.9, 0.3, 1.3); }

cubic-bezier最后那个值大于1,就是传说中的回弹。不过用的时候要克制,回弹太夸张会显得很廉价。

2.3 数据到画面的坐标映射

这里的核心问题是:后端返回的是名次,比如“3号车是冠军”,但页面上需要的是3号车停在哪个像素位置。

我的做法是:把赛道的可视宽度分成若干个档位,每个档位对应一个名次。举个例子,假设赛道总宽度是1200px,终点线区域左侧留出80px间隔,那么第1名的停车目标位置就是1200 - 80 * 1 = 1120px,第2名是1120 - 80 = 1040px,依次类推。

实际代码中我不会直接操作CSS的left,而是用transform: translateX(),理由后面在性能部分细说。逻辑上先算好目标位移值:

function calcTargetX(width, rank, total) { // 终点线右侧预留 90px 作为冠军停车区 const baseX = width - 90; // 每个名次之间的间距 const step = 60; // rank 从 1 开始,冠军位移最大 return baseX - (rank - 1) * step; }

如果车辆初始位置在赛道最左侧,比如初始translateX(0),那么最终位移就是targetX - carStartX。这里要注意一个细节:车子的初始位置不一定都是0,如果是2D赛道有交错发车位,就要在初始translateX上做差异化,否则所有车从一条竖线起跑,视觉上太死板。

2.4 出发间隔:人为制造错落感

如果所有车同时起跑,观众只会觉得这是一场排位赛,而不是“开奖揭晓”。为了让结果更有悬念,我用一个启动调度器,让车按随机小间隔依次出发,先出发的车不一定先到,因为每辆车的总时长是由最终名次决定的——名次靠前的车总动画时间略短,名次靠后的车时间略长。

举个例子:

车辆总动画时长出发延迟实际用时
3号车(第1名)4s0ms4s
1号车(第2名)4.3s200ms4.5s
6号车(第3名)4.6s300ms4.9s
2号车(第4名)4.9s500ms5.4s

出发延迟我控制在100到500ms之间随机生成,动画总时长则按名次递增150到300ms左右。这样做出来的效果是:前两秒所有车都挤在一起,看不出谁快谁慢,越到后面差距越明显——直播现场的氛围一下就起来了。

3. 实操过程与核心代码实现

3.1 搭建赛道和车辆的基础结构

先看DOM结构,非常简单清晰:

<div id="raceTrack" class="race-track"> <div class="car">.race-track { position: relative; width: 100%; max-width: 1200px; height: 500px; background: linear-gradient(180deg, #2b3a4a 0%, #1d2a36 100%); border-radius: 12px; overflow: hidden; } .car { position: absolute; left: 20px; width: 100px; height: 50px; background: linear-gradient(90deg, #ff4d4d, #d93838); border-radius: 8px 18px 18px 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); will-change: transform; transition: transform 4s cubic-bezier(0.15, 0.85, 0.25, 1); } .car-name { position: absolute; left: 8px; top: 50%; transform: translateY(-50%); color: #fff; font-size: 14px; font-weight: bold; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); }

注意几个细节:

  • 每辆车要换个颜色。我写了几个不同的颜色类,比如.car-red.car-blue.car-yellow,这样观众才能分清谁是谁。
  • will-change: transform提前告诉浏览器这个元素要发生位移,可以提前做图层优化。但不是所有元素都适合加,加多了反而吃内存,这里只有6辆车,完全没问题。
  • 终点线其实就是贴在赛道右侧的一条竖线,用::after伪元素画:
.race-track::after { content: ''; position: absolute; right: 90px; top: 0; width: 4px; height: 100%; background: repeating-linear-gradient( 180deg, #fff 0 12px, #333 12px 24px ); }

3.3 控制核心:让车“跑起来”并“停下来”

现在写最关键的控制逻辑。我定义了两个核心函数:startRaceresetRace

startRace负责接收名次数组,给每辆车设置目标位移和动画时长,然后按延迟依次让车出发。

function startRace(resultMap) { // resultMap 示例: { carId: rank } // 例: { 1: 3, 2: 5, 3: 1, 4: 4, 5: 2, 6: 6 } if (currentState !== STATE.IDLE) return; currentState = STATE.PLAYING; const cars = document.querySelectorAll('.car'); const trackWidth = document.querySelector('.race-track').offsetWidth; cars.forEach(car => { const carId = Number(car.dataset.car); const rank = resultMap[carId]; // 计算目标位移 const targetX = calcTargetX(trackWidth, rank, Object.keys(resultMap).length); const carStartX = -100; // 初始 left 是 20px,translateX(0) 代表原位,所以出发位移为负 const distance = targetX - carStartX; // 生成出发延迟和动画时长,名次越靠前,动画总时长越短 const delay = Math.random() * 400 + 100; const duration = 5000 + (rank - 1) * 250; // 写入 CSS 变量,供 transition 使用 car.style.transitionDuration = duration + 'ms'; car.style.transitionDelay = delay + 'ms'; // 强制浏览器在设置 transition 之前完成样式计算,避免动画被合并 void car.offsetWidth; // 触发位移 car.style.transform = `translateX(${distance}px)`; }); }

这里有一个很容易被忽略的坑:如果直接在设置transitionDuration的同时设置transform,浏览器可能会把“初始状态到最终状态的样式变化”合并在一次重绘里,导致没有过渡动画——直接瞬移过去。解决办法就是上面那行void car.offsetWidth;,强制浏览器进行一次重排,把样式变化“打断”成两步。这一步我一开始没写,结果所有车都瞬间到达终点,排查了十分钟才发现是这个原因。

3.4 监听动画结束与重播逻辑

动画怎么知道什么时候全部结束?不能靠瞎猜。我的做法是利用transitionend事件,给每辆车绑定完成后回调,计数达到车辆总数时,把状态切到FINISHED,并回调给外层业务系统,让主持人可以接话。

let finishedCount = 0; const totalCars = cars.length; function onCarFinish(e) { if (e.propertyName !== 'transform') return; finishedCount++; if (finishedCount === totalCars) { currentState = STATE.FINISHED; // 通知外层业务组件 if (typeof onAllFinished === 'function') { onAllFinished(); } } } // 给每辆车绑定 transitionend cars.forEach(car => { car.addEventListener('transitionend', onCarFinish); });

transitionend有个特性要注意:如果一个元素同时有多个属性发生过渡,它会对每个属性都触发一次事件。所以事件回调里必须先判断e.propertyName === 'transform',否则计数会多出好几倍,导致还没跑完就提前判定结束。

重播逻辑,也就是resetRace,核心是把所有车恢复原位,并把状态重置回IDLE

function resetRace() { // 复位过程中,为了避免动画干扰,先关闭过渡 const cars = document.querySelectorAll('.car'); cars.forEach(car => { car.style.transition = 'none'; car.style.transform = 'translateX(0)'; }); // 等一帧再打开过渡,为下一次起跑做准备 requestAnimationFrame(() => { requestAnimationFrame(() => { cars.forEach(car => { car.style.transition = ''; }); currentState = STATE.IDLE; finishedCount = 0; }); }); }

这里用双requestAnimationFrame的原因,是为了确保在把transition重新加回来之前,浏览器已经完成了“复位”样式的绘制。如果省掉中间那层请求动画帧,很容易出现复位过程也被加上过渡动画,看起来就像车在倒着飞。

4. 常见问题与排查技巧实录

4.1 车跑到一半直接瞬移到终点?

前面提到过,根本原因是样式设置和动画触发的时机被浏览器合并了。记住一个黄金法则:凡是设置了transition后要立刻改变属性值,中间必须强制一次重排。最简单的方式就是void element.offsetWidth,或者用requestAnimationFrame包一层再设置transform。这个问题的排查思路是:打开开发者工具看元素的计算样式,如果transform直接变成了目标值,且没有中间过渡帧,基本就是这个问题。

4.2 低端手机动画卡顿、掉帧

赛车的位移一定要用transform而不是lefttop。原因很简单:transform的动画可以在合成器线程直接执行,不需要每次逐帧重排重绘,而left/top的每一次变化都会触发主线程的Layout和Paint。如果直播现场用的是一台低端安卓机,用left跑的话帧率能掉到20fps以下,换到transform之后基本稳定在60fps。

另外,如果车数量多(超过10辆),建议给赛车所在的固定容器设置contain: layout paint之类的属性,帮助浏览器划定独立的渲染区域,防止动画区域内元素的变化影响页面其他部分的布局。

4.3 连续点击“重播”按钮导致时间线错乱

这个问题本质上就是状态机没做好。比如动画还在播放时点了重播,如果直接调用resetRace,上一轮还没结束的transitionend事件就会和下一轮的事件混杂在一起,计数就会爆炸。

我的解决方案是把控制逻辑全部收口:

function handleReplay() { if (currentState === STATE.PLAYING) { // 强制终止所有动画,跳过当前轮次 resetRace(); } // 等一帧,重新开始 setTimeout(() => { startRace(currentResultMap); }, 300); }

实际项目中,你在界面上要对按钮做防抖:正在播放时禁用“重播”之外的其他操作,或者点击重播时先弹确认,避免误触。这个细节看着小,但在真实的活动大屏项目中,操作人员一紧张就会疯狂点按钮,前端组件必须自己扛住这种误操作。

4.4 动画时间和直播节奏对不上

直播时最尴尬的情况是主持人已经介绍完了,车还没跑完。我的经验是给动画加一个“最短展示时间”和“跳过动画按钮”。最短展示时间可以定为4秒,也就是说哪怕所有车都已经到终点了,也要等4秒才能进入下一环节。这样可以保证主持人有固定的铺陈时间,不会因为动画提前结束而慌了节奏。

跳过动画的逻辑也很简单:如果当前状态是PLAYING,可以把每辆车的transitionDuration强制改为200ms,然后立即调用resetRace并重新设置目标位置,几毫秒内全部到位。注意跳过动画时不要走resetRace先把所有车复原,而是直接在当前状态上缩短过渡,最终把所有车的transform同时设置到目标点位,再立刻触发transitionend回调。

5. 项目的可扩展玩法与后续升级思路

5.1 从赛车延伸到通用“排名揭晓组件”

这套代码的核心其实和“赛车”无关,我提炼出的数据模型是:一组任务 + 对应的完成顺序 = 动画揭晓。所以只要换一套CSS皮肤,你就能把它改造成“骑马赛跑”“火箭升空”“小人赛跑”等不同主题。我自己的做法是把车辆节点抽成一个带carId的数据对象,CSS只负责样式,JS只负责调度,两者解耦后换主题成本很低。

5.2 实时赛事数据接入

真实场景中,名次数据往往不是一次性给定的,而是后端实时推送变化的。比如赛车比赛直播,比赛过程中名次会不断变动,前端要跟着刷新。这个场景下就不能用transitionend这种一次性事件了,我建议把动画改成“目标位置持续可更新”的模式,比如用requestAnimationFrame做每帧差值,实时插值汽车位置。

这种模式代码量会大不少,但核心思路仍是把这个组件拆成“引擎”和“外观”两层:引擎层接受目标名次并输出目标坐标,外观层负责把坐标平滑地画到屏幕上。

5.3 加音效和光效营造氛围

如果是在活动大屏或者直播场景中使用,声音是气氛的关键。起点发令可以用一声短促的蜂鸣,赛车冲刺过程中加一些引擎低频轰鸣,到达终点时配合冠军车辆闪烁光效和烟花粒子。注意所有音效都必须在动画开始前预加载好,音频资源用AudioContext预解码,避免现场点击时临时加载导致延迟或白屏。

光效的话,我建议不要用gif图片,而是用CSS粒子或者Canvas叠加实现,否则像素风格和颗粒感会很出戏。如果前端水平足够,可以尝试用Three.js做3D视角的赛道和火箭发射动画,视觉冲击力会提升一个档次,但那就是另外一个复杂度的项目了,留给有兴趣的朋友自己折腾。

6. 最后的经验总结

做这个赛车开奖动画源码的过程,让我最深地体会到:动画开发不是纯视觉工作,它其实是一种“时间管理”。你要在一个固定时间段内,让多个对象按照指定的时序关系各自完成动作,并且在任意时刻都能响应外界的打断和重来要求。这也是为什么前端动画要比UI静态图形设计难很多的原因——多出来的那个时间维度,会让所有代码逻辑都复杂一个量级。

我个人实操中的建议是:不要急着写样式,先把状态机画清楚。把什么时候能重播、什么时候能跳过、什么时候必须等待、什么时候通知外层,这几个关键节点都想明白,后面写代码就是按图索骥。另外,动画参数(时长、延迟、缓动曲线)一定要集中配置在一个常量大对象里,不要散落在代码各处。不然产品经理一句“中间那段再慢一点”,你就要在十几个文件里找那种写死的transitionDuration,找到崩溃。

这套源码的核心逻辑和坑我已经整理在上面的代码片段里了,如果你正好有类似的需求,直接拿去改改颜色和赛道布局就能用。祝你的赛车动画跑得稳、跑得漂亮,现场效果拉满。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 9:39:26

无脚本自动化工作流:打通NAS、电脑与通讯平台的AI工具环境

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 9:36:18

嵌入式固件工程化:启动流程深度拆解与OTA升级实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 9:33:54

Slopcodebench:AI代码生成质量评估与工程实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 9:32:41

CTF Crypto实战:从XOR加密原理到密钥爆破与pycryptodome安装避坑

简介&#xff1a;【广东大学生网络攻防大赛】Crypto方向crypto-xor2题目附件&#xff0c;面向参赛选手及密码学初学者&#xff0c;专门用于练习异或&#xff08;XOR&#xff09;加密密文的分析与还原&#xff0c;也适合赛前突击或课堂教学使用。整个压缩包仅两个文件&#xff0…

作者头像 李华