「星星许愿池」实现方案

需求概括: 每轮许愿时间为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);
        }
    }

作者介绍

  • 连晓霞 高级前端开发工程师

微鲤技术团队

微鲤技术团队承担了中华万年历、Maybe、蘑菇语音、微鲤游戏高达3亿用户的产品研发工作,并构建了完备的大数据平台、基础研发框架、基础运维设施。践行数据驱动理念,相信技术改变世界。