需求概括: 每轮许愿时间为30s,投注期间用户可选择许愿池投注,同时展示投注动效,在线人数以及当前在线用户投注情况需实时更新。30s投注结束后,展示中奖动画,以及返幸运币动画,此期间不可投注。完成之后,开启下一轮。
技术选型
该活动的重点是页面数据需要实时刷新,之前也有页面需要主动刷新的情况,比如养鸡游戏,但是实时性要求没有这么高,采用的是页面轮询接口的方式,很显然,轮询接口不能实现这个需求。
a. 基于 ajax 的轮询
● 定时或者动态相隔短时间内不断向服务端请求接口,询问服务端是否有新信息;缺点很明显:多余的空请求(浪费资源)、数据获取有延时;
b. 全双工通信websocket
● 建立于 TCP 协议之上的应用层;
● 一旦建立连接(直到断开或者出错),服务端与客户端握手后则一直保持连接状态,是持久化连接;
● 服务端可通过实时通道主动下发消息;
● 数据接收的「实时性(相对)」与「时序性」;
c. 借助客户端
● 目前我们服务端有接入云信,服务端可实时通知客户端;
● 客户端通过调用前端页面的方法,将数据传到前端,前端接收到数据,更新页面信息。
最终出于我们当时的情况,开发周期短,且服务端之前未接入过websocket协议,容器分布式,服务器之间还要同步数据,实现过程未知的坑较多,采用方案c,基于云信的服务,缺点是需要依赖服务端发版。
实现过程
服务端定义的消息格式
{
"page_flag": "starWishSport",
"event_id": "sdfsd",
"event_time": 1652781252462,
"event_data": { // 客户端调用网页js时传递该信息
"event_id": "sdfsd",
"event_time": 1652781252462,
"event_type": "1001",
"data": {} // 事件具体的数据信息
}
}
前端提供 Js 方法来接收客户端传递的事件。 其中有五种消息类型,前端根据接受到的消息类型响应。
- 2001 开局消息
- 2002 在线人数消息
- 2003 投注消息
- 2004 不能投注消息
- 2005 开奖消息
前端动画实现
实现星星投掷动画
css3
实现动画,首先想到的就是css3,animation 合并写法
animation: name duration timing-function delay iteration-count direction fill-mode play-state;
让一张星星图片从一个位置平滑移动到另一个位置,这很简单
.star{
animation: move 2s linear 1 forwards ;
}
@keyframes move {
0% {
left: 0;
bottom:0;
}
95%{
opacity:1;
}
100% {
left: 2rem;
bottom:4rem;
opacity:0;
}
}
还可以再添加位置偏移量,缩放效果。 我们要实现的是一个连续的动画,这就需要js操作动态延时添加dom节点,另外还需要手动清除节点。以防节点过多带来的性能问题。
canvas
1. 初始化
// 页面元素上新建 canvas 标签,初始化 canvas
<canvas id="thumsCanvas" class="star_canvas" width="750" height="730" ></canvas>
2. 加载图片资源
loadImages() {
const images = {
star: 'http://imgcom.static.suishenyun.net/h5/44ccc709-e853-4877-a9c7-5351f467a784.png',
coin: 'http://imgcom.static.suishenyun.net/h5/51c053ac-0e1e-475e-9037-8bf3d97f5457.png',
};
const src = images[this.type];
const p = new Promise(function (resolve) {
const img = new Image();
img.onerror = img.onload = resolve.bind(null, img);
img.src = src;
});
p.then((img) => {
this.ani_img = img;
});
}
3. 创建渲染对象
实时渲染图片,使其变成一个连贯的动画,很重要的是:运动轨迹。 我们要实现的动画有两种: ‒ 投掷动画,用户投掷时,从用户余额处移动到对应的许愿池(当前用户投掷),从在线人数处移动到对应的许愿池(其他用户投掷); ‒ 返金币动画,用户中奖时,从中奖许愿池移动到用户余额处 我们已知的是动画的起点和终点,通过起点和终点的x、y坐标得到动画的运动方向direction、运动速度speed、运动角度angle,在加上随机的左右偏移量,得到图片在画布上的位移量。
createRender() {
if (!this.ani_img) return null;
const context = this.context;
// 渲染图片
const image = this.ani_img;
const basicX = this.from.x;
const basicY = this.from.y;
const direction = this.from.y - this.to.y > 0 ? -1 : 1; // 1(向上) -1 (向下)
const speed = Math.sqrt(Math.pow(Math.abs(this.from.x - this.to.x), 2) + Math.pow(Math.abs(this.from.y - this.to.y), 2)) / Math.abs((this.from.y - this.to.y) / (this.from.x - this.to.x)) / 5;
const angle = Math.abs((this.from.y - this.to.y) / (this.from.x - this.to.x));
let offsetX = getRandom(15, -15, 10, -10, 5, -5);
const getTranslateX = (diffTime) => {
if (this.from.x - this.to.x > 0) {
return basicX - diffTime * 10 * speed + offsetX;
} else {
return basicX + diffTime * 10 * speed + offsetX;
}
};
const getTranslateY = (diffTime) => {
if (this.from.y - this.to.y > 0) {
return basicY + speed * angle * (-diffTime * 10);
} else {
return basicY + speed * angle * (diffTime * 10);
}
};
return (diffTime) => {
// 差值满了,即结束了 0 ---》 1
if (diffTime >= 1) return true;
context.save();
const translateX = getTranslateX(diffTime);
const translateY = getTranslateY(diffTime);
context.translate(translateX, translateY);
if (direction * (translateY - this.to.y) < 0) {
context.drawImage(
image,
-image.width / 2,
-image.height / 2,
30,
30
);
}
context.restore();
};
}
diffTime,是指从开始动画运行到当前时间过了多长时间了,为百分比。实际值是从 0 --》 1 逐步增大。 diffTime 为 0.4 的时候,说明是已经运行了 40% 的时间。
4. 实时绘制
scan() {
this.context.clearRect(0, 0, this.width, this.height);
this.context.fillStyle = 'transparent';
this.context.fillRect(0, 0, this.width, this.height);
let index = 0;
let length = this.renderList.length;
if (length > 0) {
requestFrame(this.scan.bind(this));
this.scanning = true;
} else {
this.scanning = false;
}
while (index < length) {
const child = this.renderList[index];
if (!child || !child.render || child.render.call(null, (Date.now() - child.timestamp) / child.duration)) {
// 结束了,删除该动画
this.renderList.splice(index, 1);
length--;
} else {
// continue
index++;
}
}
}
将创建的渲染对象放入 renderList 数组,数组不为空,说明 canvas 上还有动画,就需要不停的去执行 scan,直到 canvas 上没有动画结束为止。
5. 开始动画
start() {
const render = this.createRender();
const duration = getRandom(1500, 2000);
this.renderList.push({
render,
duration,
timestamp: Date.now(),
});
if (!this.scanning) {
this.scanning = true;
requestFrame(this.scan.bind(this));
}
return this;
}
function requestFrame(cb) {
return (
window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
function (callback) {
window.setTimeout(callback, 1000 / 60);
}
)(cb);
}
其他问题
1. 实现点击穿透
由于canvas画布的层级最高,所以需要实现点击穿透
/* 实现点击穿透 */
pointer-events: none;
2. 许愿结束图片动效
transition 给元素和组件添加进入/离开过渡
<transition name="cover">
<img v-show="show_end_cover" class="end-cover" src="http://imgcom.static.suishenyun.net/h5/003716eb-8e05-4161-8eac-9e515d9b2754.png" alt="">
</transition>
.cover-enter-active {
animation:fadeInRightBig 1s .2s ease both;
}
.cover-leave-active {
animation:fadeOutRightBig 1s .2s ease both;
}
@keyframes fadeInRightBig{
0%{
opacity:0;
transform:translateX(750px)
}
100%{
opacity:1;
transform: translateX(0)
}
}
@keyframes fadeOutRightBig{
0%{
opacity:1;
transform:translateX(0);
}
100%{
opacity:0;
transform:translateX(750px);
}
}
作者介绍
- 连晓霞 高级前端开发工程师