简介:赛车开奖动画源码是一套基于HTML、CSS与JavaScript的交互式赛车开奖动画程序,面向需要快速搭建开奖展示页面的前端开发者或活动运营者,解决开奖过程枯燥、结果可被质疑的问题。代码采用模块化目录结构,支持自定义赛车样式、赛道设计和动画速度,并预留第三方随机接口,确保开奖结果公平不可预测。资源包共40个文件,以30张PNG图片和4张JPG图片为主,提供赛车与赛道等视觉素材;2个JS文件负责动画逻辑与接口通信,2个CSS文件控制整体样式,另含1个HTML主页面和1个GIF动图,整体仅789KB,轻量易部署。目前已有745人学习查看,这类完整小巧的开奖动画源码很适合作为课程设计、活动页面的参考原型。压缩包内附完整项目结构,包含主页面与全部配套资源,开发者可在此基础上修改动画细节或接入自己的后端接口。
1. 赛车开奖动画源码:先看它能解决哪一类需求
上个月某公司的运营活动临时加了一个需求:比赛结果出来后,页面上要立刻播一段几辆车从起点冲刺到终点的动画,播完才亮名次,整体五秒内结束。运营文案里常把这类动效叫“赛车开奖动画”,技术上说,它是一个按预定结果回放的竞速播报动画。这套源码场景收敛得很明确:六辆以内赛车、一条横置赛道、前端不参与胜负计算、结果完全由业务数据驱动。它适合前端开发拿来做活动页的赛事结果展示,也适合想快速抄一套 Canvas 动效骨架、再替换成自己美术素材的开发者。接下来的内容会把它拆到能直接改配置、接接口、处理线上问题的程度。
2. 渲染选型与源码结构:为什么这次不选 WebGL
做动效先选渲染方案,这个选择一旦做错,后面所有代码都要推倒重来。这个包用的是 Canvas 2D,画面上所有车、赛道、终点线都绘制在一个<canvas>上。下面把三个候选方案的实际边界说清楚。
2.1 渲染方案三选一:CSS 动画、Canvas 2D、WebGL 的取舍
CSS 动画做“平移”确实简单,一辆车从起点到终点只需要一个transform: translateX()就能跑起来。但是这套源码的需求不止是平移:车在接近终点时要制造小幅追近的视觉效果,名次要按外部给的固定数组错峰到达,车身上要挂号码牌和名次角标。用 CSS 写这些跨元素耦合的状态会非常绕,尤其是“按 rank 推到达时间”这件事,CSS 的 timeline 很难精确控制多个对象在同一个时间轴上的先后顺序。
WebGL 在这个场景属于过度设计。六辆 2D 小车、一条横向赛道,为了它引入 shader 编译、纹理上传、顶点缓冲管理,调试成本直接翻几倍。Canvas 2D 的 API 简单直观,单帧绘制次数可控时,在主流安卓机和 iOS 设备上都能稳定跑满 60fps,这是它能被选中的根本原因。
| 对比维度 | CSS animation | Canvas 2D | WebGL |
|---|---|---|---|
| 多车状态同步 | 依赖 timeline,跨车耦合困难 | 一个 update() 统一计算 | 需要在着色器外维护状态 |
| 曲线路径与换道 | 可做但实现绕 | fillRect / arc / drawImage 直接支持 | 需要自己组装几何 |
| 贴图与换肤 | 受样式覆盖规则影响 | drawImage 换图即可 | 纹理上传有额外开销 |
| 调试成本 | 排查样式层叠问题 | console.log 直接看坐标和状态 | 要看 shader 编译日志 |
| 学习成本 | 最低 | 低 | 高 |
结论很直接:2D 动效、对象数量在两位数以内、结果由外部数据驱动,Canvas 2D 是性价比最高的方案。这个源码没有为了炫技引入任何重量级的渲染库,核心文件加起来不到两个脚本,这个体量正好匹配它的使用场景。
2.2 源码目录与文件职责:先读哪三个文件
拿到这个包,不要急着打开 index.html 跑一遍就完事。先看结构,看清楚每个文件的职责边界,后面改起来才不会东一榔头西一棒。这套源码的文件划分很克制,核心就四份:
- index.html:页面上只有一个
#race-stage的 canvas 容器和一个结果面板,脚本放在底部,入口是 main.js。 - css/style.css:负责页面布局、按钮、结果列表的显隐控制,不参与 canvas 内部渲染。
- js/raceEngine.js:引擎本体。创建 canvas 上下文、维护赛车状态、跑 requestAnimationFrame 主循环、派发播报事件。这是整个包里最值得反复读的文件。
- js/raceData.js:默认配置和演示数据。后面改主题、改配色、改赛道长宽都先看这里,不用动引擎。
- js/main.js:入口,把按钮点击、WebSocket 消息、引擎实例串起来。
阅读顺序建议是raceData.js → raceEngine.js → main.js。先看配置,知道有哪些可调参数;再看引擎的start()与update()两个方法,理解动画是怎么被驱动起来的;最后回到入口,看业务数据是怎么喂进去的。
2.3 画布尺寸与自适应:写死宽高是第一个坑
很多人在接这种动效时第一件事就是把 canvas 的宽高写死成 1920×600。这个源码的做法是:用container.getBoundingClientRect()读取容器实际宽高,再拿window.devicePixelRatio做高清屏适配,最后赋值给 canvas 的width和height属性。注意canvas.style.width是 CSS 像素尺寸,canvas.width是位图像素尺寸,这两者不能混用。
// main.js 画布初始化片段 const stage = document.getElementById('race-stage'); const rect = stage.getBoundingClientRect(); const dpr = Math.max(window.devicePixelRatio || 1, 1); canvas.width = Math.round(rect.width * dpr); canvas.height = Math.round(rect.height * dpr); canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);这段代码里setTransform(dpr, 0, 0, dpr, 0, 0)是关键。它把后续所有绘制坐标统一换算成 CSS 像素坐标,写坐标时不用再手动乘 dpr。初始化之后这段逻辑不要重复执行,否则 canvas 位图会被重新分配,已经画好的离屏缓存全部失效。这个细节在后文的避坑章节还会遇到。
3. 核心动画拆解:从赛道初始化到名次撞线
这一章进入引擎内部。赛车开奖动画的难点不在“画一辆车”,而在“让多辆车按预定名次错峰到达终点,同时视觉上还保留悬念”。源码里最核心的设计都在这里。
3.1 数据模型与赛道参数:车、道、距都从配置来
赛车、赛道、时间这三个维度的参数全部集中在raceData.js的defaultConfig对象里。这个包的默认参数针对六车道、五秒内播完的场景标定,改业务时优先动这里。
// raceData.js 核心配置 const defaultConfig = { laneCount: 6, // 赛道数量,超过 6 视觉上会拥挤 laneHeight: 56, // 单条赛道的像素高 startX: 70, // 起点线 X 坐标 finishX: 920, // 终点线 X 坐标 baseDuration: 4200, // 第一名从起点到终点的时间,单位 ms rankGapMs: 180, // 相邻名次到达终点的时间差,越小悬念越足 trackSpriteUrl: '', // 赛道背景图片,空字符串时用纯色绘制 racerSpriteSize: 48 // 赛车贴图尺寸,会做离屏裁剪 };baseDuration和rankGapMs是整套动画的节奏灵魂。baseDuration决定第一名多久到达,rankGapMs决定第二名比第一名晚多少毫秒撞线。它们之间是线性叠加关系:假设第一名 4200ms 到达,第二名就是 4380ms,第三名 4560ms,依此类推。这个设计比“每辆车固定速度”高级的地方在于,它能保证后车永远追不上前车,但视觉上又是同时加速冲线。
赛车建模同样简洁:
// raceEngine.js 赛车建模 class Racer { constructor(info, laneIndex, config) { this.name = info.name; this.color = info.color; this.rank = info.rank; // 预定名次,1 表示最先撞线 this.laneIndex = laneIndex; // 从 0 开始的车道序号,只决定位置 this.progress = 0; // 0~1,1 表示已撞线 this.x = config.startX; this.y = config.trackTop + laneIndex * config.laneHeight + config.laneHeight / 2; this.sprite = buildRacerSprite(config.racerSpriteSize, info.color); } }注意laneIndex和rank是彻底解耦的。车道序号只决定这辆车摆在哪条跑道,rank决定它什么时候到达终点。这样业务方传排名数组时,不需要关心车停在哪条道,引擎内部根据 rank 推导时间,根据 laneIndex 摆放位置。
3.2 主循环与位移方程:时间差才是进度,帧数不是
主循环是动画发动机。很多初学写的动效会在requestAnimationFrame里靠“每帧移动 3 像素”这种方式推位移,一旦碰到帧率波动,车就会忽快忽慢。这个源码用的是时间差驱动:每次 rAF 回调都会收到一个now时间戳,用它减去动画开始时间得到 elapsed,再除以目标时长得到进度,最后映射到 X 坐标。
// raceEngine.js 主循环骨架 start(payload) { if (this.state === 'running') { this.reset(); // 防重入:上一场还没播完就切新数据 } this.buildRacers(payload.racers); this.state = 'running'; this.startTime = performance.now(); const loop = (now) => { if (this.state !== 'running') return; this.update(now); this.render(); if (this.racers.every(r => r.progress >= 1)) { this.state = 'settled'; this.emit('settle', this.buildRanking()); return; } this.rafId = requestAnimationFrame(loop); }; this.rafId = requestAnimationFrame(loop); }loop是箭头函数,这样它绑定的是创建时的this,回调里访问this.state不会丢上下文。this.rafId被保存下来,是因为后面reset()里需要cancelAnimationFrame挂起这条链,避免上一场的循环还在跑、下一场的循环又开始跑。
位移推进全部在update()里完成:
// raceEngine.js 位移推进 update(now) { const elapsed = now - this.startTime; for (const r of this.racers) { if (r.progress >= 1) continue; const rawT = elapsed / this.targetTimeOf(r); // 0~1 线性进度 const t = Math.min(Math.max(rawT, 0), 1); r.progress = easeInOutCubic(t); r.x = this.config.startX + (this.config.finishX - this.config.startX) * r.progress; r.y = this.config.trackTop + r.laneIndex * this.config.laneHeight + this.config.laneHeight / 2; } } targetTimeOf(r) { return this.config.baseDuration + (r.rank - 1) * this.config.rankGapMs; }位移曲线用的是 easeInOutCubic:前半段加速慢,中段咬合紧,最后一段减速。如果改成线性函数,第一名会从头领先到尾,观众一眼看穿结局,播报动效的“仪式感”就没了。
这里解释一个很容易踩的误区:rawT除以的是targetTimeOf(r),这个值对每辆车是不同的。后车的时间线比前车短,但因为曲线是缓进的,后车在视觉上前半段不会立刻被甩开,而是到冲刺阶段才体现差距。这种“前面咬住,终点才分离”的效果,正好符合赛事播报的节奏。
3.3 结果怎么演出来:rank 决定到达时间,速度只是包装
这一部分是整个源码里最反直觉的设计,也是它区别于普通“赛车游戏”的地方。普通赛车动画是每辆车按自己的速度跑,谁先到终点谁赢;开奖播报类动画是结果已经定好,动画只是把结果演出来。业务方给一个 rank 数组,引擎为每个 rank 分配一个到达时间,然后用位移曲线把这段时间填满。
视觉包装上有几个细节:
- 终点线前 10% 的距离,领先车会被曲线强制减速,后车在同一区间内加速追赶,但永远不会超过前车,因为后车的 targetTime 本来就比前车长。
rankGapMs越小,所有车撞线时间越密集,悬念感越强。默认 180ms 会让人觉得是“同时冲线但拍照判定分出先后”,这个体感是大多数运营想要的。- 撞线发生后,
progress被钳制在 1,X 坐标停在finishX,后续帧不再推进,但车仍然被绘制在画面上,直到reset()被调用。
渲染层同样克制:
// raceEngine.js 渲染层 render() { const ctx = this.ctx; ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight); ctx.drawImage(this.trackSprite, 0, 0); ctx.fillStyle = '#f8f9fa'; ctx.fillRect(this.config.finishX - 4, 0, 4, this.canvasHeight); for (const racer of this.racers) { ctx.drawImage(racer.sprite, racer.x - 24, racer.y - 28); } }赛道背景trackSprite和每辆车的sprite都是离屏预渲染出来的:初始化时先在内存 canvas 上画好,运行时每帧只做drawImage拷贝。整帧的绘制调用被压缩到十次以内,这是它能跑满 60fps 的根本原因,而不是靠什么黑魔法。
4. 结果播报接入:把固定排名转成动画事件
源码和业务之间的耦合点只有两个:一个数据入口(启动动画),一个数据出口(结算回调)。看明白这两个口,接任何后端结果推送都不需要改引擎。
4.1 播报数据结构与状态机:输入输出都是纯数据
业务方推到前端的播报数据,结构非常轻量:
// 业务方推送的播报数据 const racePayload = { raceId: 'R20240112-03', racers: [ { name: '疾风', color: '#e03131', rank: 1 }, { name: '猎鹰', color: '#1971c2', rank: 2 }, { name: '闪电', color: '#f08c00', rank: 3 }, { name: '银翼', color: '#868e96', rank: 4 } ] };引擎只认racers数组里的name / color / rank三个字段,raceId只是透传给回调,不做内部逻辑。这个设计保证了业务层可以自由扩展字段,比如加 headImg、加赔率信息,引擎不会因为多一个字段就崩。
状态机固定在三种状态之间切换:
| 状态 | 触发条件 | 可执行操作 |
|---|---|---|
| idle | 构建完成或 reset 后 | 可调用 start(payload) |
| running | start(payload) 被调用 | 更新进度、渲染、可取消或重置 |
| settled | 所有 racer.progress >= 1 | 读取结果,重播必须先 reset |
这套状态机的价值在于,外部调用方任何时候只需要检查state字段就能决定下一步动作,不需要关心引擎内部的 rAF 链状态。
4.2 初始化、回调与 WebSocket 对接:动效要挂到业务链路上
初始化发生在入口脚本main.js,引擎实例化时传入配置和结算回调:
// main.js 初始化引擎 const raceEngine = new RaceEngine(document.getElementById('race-stage'), { baseDuration: 4200, rankGapMs: 180, onSettle: (result) => { // result 已按 rank 排序完成,可直接渲染到结果面板 renderResultList(result); } }); // 按钮触发 document.getElementById('play-btn').addEventListener('click', () => { if (raceEngine.state === 'running') return; // 防连点 raceEngine.start(loadDemoPayload()); });实际业务中,结果几乎不会来自本地按钮,而是来自服务端推送。常见做法是走 WebSocket 长连接,推送到达的瞬间启动动画:
// 假设业务用 WebSocket 推送比赛结果 const socket = new WebSocket('wss://your-host/race-channel'); socket.addEventListener('message', (event) => { const message = JSON.parse(event.data); if (message.type === 'race.result') { raceEngine.reset(); // 关键:先复位上一场状态 raceEngine.start(message.payload); } });reset()是很容易漏掉的一步。如果不先复位,上一场的赛车会残留在画布上,新车再挤上去,画面就叠成一团。源码里start()内部其实也带了一次 reset 保护,这段代码保留显式 reset 是为了可读性和边界防御,线上环境宁可多一次空操作,也不能赌调用时机刚刚好。
4.3 可复用配置参数:一个引擎跑多个主题
整套源码的扩展性都集中在一个配置对象上。除了前面看到的baseDuration和rankGapMs,还有几个适合业务接入时调整的项:
| 配置项 | 默认值 | 说明 |
|---|---|---|
| laneCount | 6 | 赛道数,同时决定画布高度基准 |
| laneHeight | 56 | 单赛道高,数值越小赛道越紧凑 |
| baseDuration | 4200 | 第一名到达终点的毫秒数,越长越有仪式感 |
| rankGapMs | 180 | 相邻名次撞线时间差,设 120~180 更有悬念,300 以上名次分明 |
| showRankTag | true | 撞线后是否展示名次角标 |
| autoReset | false | settle 后多少毫秒自动复位,false 表示手动复位 |
我给这个参数提一个使用建议:rankGapMs是这个包里最值得调的旋钮,但它的上限不要超过 1000。超过 1000 毫秒,观众在最慢的车还没跑过一半时就可能离开页面。同理,baseDuration低于 2500ms 时胜负感太急促,高于 6000ms 时用户会觉得拖沓,默认的 4200ms 是多数活动页能接受的临界点。
5. 避坑排查:五个高频问题与现场追因
动画本身不难,难的是各种边界情况下动画表现不一致。这一章写的是从实际项目里带出来的五条踩坑记录,源码里不会主动注释这些,全靠运行期暴露。每条按“现象 → 原因 → 解决”拆开,可以直接对照排查。
5.1 切后台回来动画瞬移
现象:页面切到后台再切回来,播报动画直接跳到结尾,或者某辆车瞬间出现在终点线。
原因:这个坑来自用固定步长累加位移的写法——每帧x += 5,浏览器切后台时 rAF 暂停,步长累加停止,但现实时间在走。回到前台时,浏览器连续补帧,引擎用剩余的数据一次性跳到终点,表现就是瞬移。
解决:位移推进一律改用 rAF 回调里的now时间戳,计算elapsed = now - startTime,再除以目标时间得到进度。这样切后台再回来,elapsed会一次性跳到真实值,但update()里会对t做了clamp(0, 1)钳制,结果只是直接切到结算状态,不会出现中间状态的乱跳。
5.2 低端机掉帧与机身发热
现象:在某千元机上跑,帧率只有 20fps 出头,播完一场动画手机背部明显发热。
原因:每帧都对赛车做了十几条路径绘制——轮胎、车壳、尾翼、阴影、号码牌,加上赛道划线全部用fillRect逐条打,单帧绘制调用超过 150 次。旧设备的 2D canvas 光栅化线程被持续拉满,发热随之而来。
解决:把赛车的静态造型提前画到离屏 canvas 上,运动时只drawImage整张图。赛道背景(草地、柏油、起点网格线)也离屏画一次。优化后单帧绘制次数降到两位数以内,这个场景在低端机上也能守住 40fps 以上。
5.3 高分屏画面发虚
现象:在 Retina 屏幕上,车身轮廓和号码牌边缘都毛毛糙糙,像位图被硬拉大了一个尺寸。
原因:canvas 元素的 CSS 宽度和内部位图像素宽度不一致。高分屏默认把 canvas 位图按逻辑分辨率放大绘制,没有做devicePixelRatio适配,等于一张 1x 的图被强行拉伸到 3x 显示。
解决:初始化时读取window.devicePixelRatio,把canvas.width设为cssWidth * dpr,再调用ctx.setTransform(dpr, 0, 0, dpr, 0, 0)。后面所有坐标继续按 CSS 像素写,清晰度立刻上一个档次。
5.4 终点前提前剧透名次
现象:动画播到一半,领先车已经拉开两个身位,结果毫无悬念,运营反馈“节奏不对,像假赛”。
原因:rankGapMs设得太大,例如 500ms 以上。领先车在 60% 进度时已经领先了视觉上一个多车位,悬念在动画中途就耗尽了。
解决:把rankGapMs缩到 120~180ms 之间,并确认位移曲线用的是终点前减速的easeInOutCubic。目视感受是前车中段加速甩开,最后一段后车贴上来但超不过,撞线前后才分出名次。
5.5 快速连点导致画面叠车
现象:一场播完立刻点“再播一次”,画面上出现两批赛车混跑,或者上一场的车定在终点不动。
原因:start()没有在动画运行中做保护,或者reset()里没有取消上一场挂起的 rAF,导致同一个 canvas 上同时跑两条渲染链。
解决:在start()入口判断state === 'running'时先执行reset();reset()内部必须调用cancelAnimationFrame(this.rafId),再把每辆车的progress清零、X 坐标复位到起点。外部再在按钮上做一层防抖,双保险才能把这种手滑操作挡在引擎外。
6. 验证与扩展:把播报动画做成基础组件
6.1 用 Performance API 做性能预算验证
接入这套源码后,不要只看桌面浏览器上跑得顺就交付。在loop()里临时加一段耗时埋点,用数据说话:
// 临时验证帧耗时,正式发布前删掉 const t0 = performance.now(); this.update(now); this.render(); const cost = performance.now() - t0; if (cost > 10) { console.warn('单帧耗时超标:', cost.toFixed(2) + 'ms'); }判定线参考:连续 100 帧平均耗时 ≤ 8ms,且单帧超过 10ms 的次数不超过 5%,就具备中端机 60fps 的余量。如果超标,优先检查三个位置:drawImage次数是否暴增、是否每帧用fillText重绘了动态文本、有没有在绘制路径上开启全局 shadow。这三个位置几乎覆盖了这种动效的绝大多数性能问题。
6.2 三个扩展技巧:慢速回放、主题切换、音效联动
慢速回放不用改状态机。播报数据里增加一个durationFactor字段,把baseDuration乘上这个系数即可,比如 1.8 就是 1.8 倍慢放。它与rankGapMs是相乘关系而不是相减关系,慢放时名次间距也被等比例拉长,逻辑上不会乱。
主题切换的核心是把配色、贴图 URL、终点样式全部收进一个theme对象放在raceData.js。引擎只认theme字段,不认业务名。换赛事主题时只替换theme,绝不复制整个引擎。
音效联动放在onSettle回调里触发,撞线瞬间同时播放短促的收尾音。很多项目动效和音效分开走两个 timer,结果音效比画面慢半拍,正确做法是让音频在onSettle同一帧触发,这样才能做到声画同步。
从接手这个包之后我定了一条工作流:凡是接类似的播报动效,先确认三件事——结果是不是后端给固定数组、页面会不会被切后台、最低端测试机是什么级别。然后每次改完配置,都强制走一遍三连验证——切后台回前台、低端机试跑一次、快速双击按钮再触发一次。这套源码我现在一直当内部基础组件用,换业务只改数据和主题,引擎本身很少再动。希望帮到你。
本文还有配套的精品资源,点击获取