多标签通讯--实现用浏览器接小球游戏

2 阅读9分钟

output3.gif

一、前言

上一篇文章通过学习一个元素在不同浏览器标签的拖动,产生变魔术一样的效果,用最简的方式学习了其中的原理,接下来实现一个用浏览器窗口接小球的游戏,仍然是最简的代码实现, 只实现最核心的功能, 比如控制小球个数, 下降速率, 其余的比如成功失败,等游戏逻辑待自行补充完善

猛戳 👇 浏览器跨标签星球火了,简单探究一下实现原理

output3.gif

二、功能分析

实现浏览器接小球分以下几个步骤

  • 先实现单机版本的,同一个标签内实现一个元素接小球,用来调试,道理是一样的
  • 设计小球的类、设计游戏的类, 希望可配置小球的数量、控制小球的下降速度、控制小球按一定时间间隔产生,不要一下子出来
  • 性能考虑,优化代码
  • 实现用另一个浏览器窗口接小球, 需要通讯,坐标转化
  • 监听浏览器窗口的移动

难点

窗口静止时仍然可以接小球,小球下降的全阶段,以及浏览器窗口拖动的全过程都可以准确的拿到坐标进行碰撞检测

多小球实现不同小球的不同时间生成,销毁,反转,下降速度控制

抽离功能,分层处理,代码组织, 一套代码实现两种不同类型的接小球

三、 代码实现

1、 渲染小球,设置小球类

分析完功能后, 进行代码编写

首先编写小球这个类, 预留想要设置小球属性的类, 然后通过render渲染到页面上


class Ball {
  constructor(size = 3, color = "#000") {
    // 设置尺寸
    this.size = size;
    // 设置颜色
    this.color = color;
    // 创造标签
    this.ballDom = document.createElement("div");
    this.top = 0;
    // 是否反转
    this.revert = false;
  }
  render() {
    this.left = Math.floor(Math.random() * window.innerWidth);
    this.ballDom.style.background = this.color;
    this.ballDom.style.width = this.size * 10 + "px";
    this.ballDom.style.height = this.size * 10 + "px";
    this.ballDom.style.left = this.left + "px";
    this.ballDom.style.top = this.top + "px";
    this.ballDom.style.borderRadius = "50%";
    this.ballDom.style.position = "fixed";
    document.body.appendChild(this.ballDom);
  }
}

上面是在生成小球的时候,小球需要的属性,以及渲染小球的方法, 接下来我们先让他渲染出来, 写一个游戏的类,先生成一个小球

2、实现游戏类,完成一个小球的渲染

 class Game {
    constructor(nums = 10) {
        this.maxNums = this.nums;
      }
      generateBalls(nums = 1, speed = 0.05) {
        // 生成小球
        this.curball = new Ball();
        // 渲染
        this.curball.render();
      }
      // 游戏开始
      start(nums, speed) {
        game.generateBalls(nums, speed);
      }
    }
    const game = new Game();
    game.start(3, 10);

实现游戏类, 通过参数传入生成的小球数量是多少, 再游戏开始的时候传入生成小球的下降速度, 不同游戏难度,下降速度需要可以控制

3、 完成一个小球的下降、反转、销毁动作

接下来是重头, 小球与物体的碰撞检测主要是在下降的动作中完成, 所以该函数是重点, 我们先写一个 dropFn 方法, 传递下降速度和小球离开窗口的回调方法, dropFn(speed = 0.03, destoryFn) 启动一个 requestAnimationFrame 也可以先用 setInterval 代替, 内部不断执行,给小球的 top 增加值

dropFn(speed = 0.03, destoryFn) {
    let animationId;
    const doSomething = () => {
      // 小球超出浏览器窗口销毁
      if (isElementOutViewport(this.ballDom)) {
        cancelAnimationFrame(animationId);
        this.ballDom.remove();
        destoryFn();
      }
      // 改变小球的高度
      this.top += 1 + speed;
      this.ballDom.style.top = this.top + "px";
      // 在任务中再次调用 requestAnimationFrame
      animationId = requestAnimationFrame(doSomething);
    };
    // 启动周期性任务
    animationId = requestAnimationFrame(doSomething);
  }

此时,在 Game 类生成小球后, 再加上

// 下降速度为3, 小球销毁后,调用函数
this.curball.dropFn(3, () => {
  console.log("销毁");
});

检测元素是否在屏幕外方法

 // 检查元素是否在屏幕外
    function isElementOutViewport(el) {
      var rect = el.getBoundingClientRect();
      return (
        rect.top < 0 ||
        rect.left < 0 ||
        rect.bottom >
          (window.innerHeight || document.documentElement.clientHeight) ||
        rect.right > (window.innerWidth || document.documentElement.clientWidth)
      );
    }

至此,刷新浏览器,就会看到一个随机位置生成的小球向下坠落,并且速度可以控制,接下来就是实现与同屏元素的碰撞检测及小球反转销毁

4、实现小球与物体的碰撞检测,反转

首先先思考小球与物体进行碰撞的条件

  • 小球的左边坐标x >= target的x坐标
  • 小球的右边坐标 <= target的左边坐标加宽度
  • 小球的top >= 目标的top
 // 碰撞检测
  _checkCollision(successFn) {
    const ballTop = this.top + this.size * 10;
    const ballLeft = this.left;
    const ballRight = this.left + this.size * 10;
    if (!window.bounding) return;
    if (
      ballTop >= window.bounding.top &&
      ballLeft >= window.bounding.left &&
      ballRight <= window.bounding.right
    ) {
      successFn?.();
    }
  }

上面便是代码上的实现, 在dropFn 中调用实时检测,当目标也就是 window.bounding 的坐标符合时,触发成功回调函数, 设置小球的 revert 属性为 true, 然后小球的top 开始 --

 // 元素或者浏览器静止时也需要,检查
  this._checkCollision(() => (this.revert = true));
  // 掉转方向
  if (this.revert) {
    this.top -= 1 + speed;
  } else {
    // 改变小球的高度
    this.top += 1 + speed;
  }

现在还差目标的 bounding, 为什么设置在全局?

  // 生成小球
  generateBalls(nums = 1,speed = 0.05,controlCallbackFn){
  //...
  // 每次移动元素或者浏览器都会触发这个回调,将最新的坐标值传入
   controlCallbackFn((bounding) => {
    // 通过传递发现会有更新的延迟,更新时机比较多,直接挂到全局了
     window.bounding = bounding;
   });
  }
  //...

因为生成小球时,我们需要实时通过回调,拿到目标的实时坐标, 不同的方式又有不同的处理方式, 所以设计在 game.start 的时候,通过传入函数,通过每次触发回调的方式,将最新值拿到,赋给window全局属性,方便及时拿到最新值,如果传入每个小球的话, drop 内取到的值有些许误差, 放到全局不会存在这个问题

下面是通过元素移动接小球的回调方法,再页面写一个类名为 board 元素

// 元素移动消息的回调
const controlCallbackFn = (cb) => {
  const board = document.querySelector(".board");
  const getBounding = () => {
    const boardBound = board.getBoundingClientRect();
    const targetBound = {
      left: boardBound.left,
      top: boardBound.top,
      right: boardBound.width + boardBound.left,
    };
    return targetBound;
  };
  // 第一次加载先执行一次
  cb(getBounding());
  board.onmousedown = function (e) {
    board.style.cursor = "pointer";
    let x = e.pageX - board.offsetLeft;
    window.onmousemove = function (e) {
      cb(getBounding());
      board.style.left = e.clientX - x + "px";
    };
    window.onmouseup = function () {
      window.onmousemove = null;
      window.onmouseup = null;
      board.style.cursor = "unset";
    };
  };
};

用元素接小球
game.start(10, 10, controlCallbackFn);
用浏览器接小球
game.start(3, 10, controlCallbackFn2);

通过上面的使用便可以看出来, 我们是想通过传入不同的获取目标变化的方法,注册回调, 每次触发回调获取最新的值, 方便跟小球准确的碰撞检测

5、生成多个小球

generateBalls(
    nums = 1,
    speed = 0.05,
    controlCallbackFn,
    delayTime = 2000
  ) {
    // 每次移动元素或者浏览器都会触发这个回调,将最新的坐标值传入
    controlCallbackFn((bounding) => {
      // 通过传递发现会有更新的延迟,更新时机比较多,直接挂到全局了
      window.bounding = bounding;
    });
    for (let i = 0; i < nums; i++) {
      setTimeout(() => {
        // 生成小球
        this.curball = new Ball();
        // 渲染
        this.curball.render();
        // 下坠
        this.curball.dropFn(speed, (revert) => {
          if (revert) {
            this.successCount++;
            console.log("接小球成功个数", this.successCount);
          } else {
            this.failCount++;
            console.log("接小球失败个数", this.failCount);
          }
          // 销毁一个 再生成一个
          this.generateBalls(1, speed, controlCallbackFn, delayTime);
        });
        // 这里是控制小球的生成产生时间差
      }, i * delayTime);
    }
  }

生成多个小球就比较简单了, 通过延迟器, 产生一定的生成时间,然后再渲染, 每次销毁回调触发后, 再继续执行, 保证当前屏幕内元素个数是我们需要的

6、 用浏览器窗口接小球

上面实现了用元素接小球后, 用浏览器接小球就好说了, 原理就是检测浏览器的移动,位置的变化, 然后发送channel事件

/*--------浏览器窗口变化发送---------------*/
    let screenX = 0;
    let screenY = 0;
    let screenW = 0;
    // 轮训监听浏览器窗口的变化
    setInterval(() => {
      if (
        screenX !== window.screenX ||
        screenW !== window.outerWidth ||
        screenY !== window.screenY
      ) {
        // 浏览器窗口移动了
        channel.postMessage({
          x: window.screenX,
          y: window.screenY,
          w: window.outerWidth,
        });
        screenX = window.screenX;
        screenY = window.screenY;
        screenW = window.outerWidth;
      }
    }, 100);
    // 监听浏览器缩放操作
    window.addEventListener("resize", function (event) {
      // 在窗口移动时执行的代码
      console.log("窗口位置已改变");
    });

监听窗口变化的回调的方法, 依然是构造回调函数,再小球生成的时候执行, 每次移动都会触发回调,给window.bounding 上绑定最新的值

// 窗口移动消息的回调
const controlCallbackFn2 = (cb) => {
  channel.onmessage = (event) => {
    if (event.data) {
      const [clientX, clientY] = screenToClient(event.data.x, event.data.y);
      const targetBound = {
        top: clientY,
        left: clientX,
        right: clientX + event.data.w,
      };
      cb(targetBound);
    }
  };
};

四、总结

至此, 用窗口接小球游戏的所有主要代码就完成了, 完整代码再最后, 实现这个需求, 主要是首先要先搞明白一个窗口的如何设计, 分模块分功能完成后, 还需要寻找共同特点,分离变化的部分, 最终实现一套代码,实现元素接小球和浏览器接小球的功能, 代码默认注释了浏览器接小球, 默认是元素接小球, 可以手动打开浏览器的体验,并且再css上用visibility: hidden隐藏元素

用元素接小球
game.start(10, 10, controlCallbackFn);
用浏览器接小球
// game.start(3, 10, controlCallbackFn2);

五、完整代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>跨标签通讯</title>
  </head>
  <style>
    .board {
      width: 300px;
      height: 50px;
      background-color: #f00;
      position: fixed;
      bottom: 0;
      left: 100px;
      /* visibility: hidden; */
    }
  </style>
  <body>
    <div class="board">我是可以移动的元素</div>
    <div class="gamestart"></div>
    <div class="gameover"></div>
  </body>
  <script>
    // util
    // 检查元素是否在屏幕外
    function isElementOutViewport(el) {
      var rect = el.getBoundingClientRect();
      return (
        rect.top < 0 ||
        rect.left < 0 ||
        rect.bottom >
          (window.innerHeight || document.documentElement.clientHeight) ||
        rect.right > (window.innerWidth || document.documentElement.clientWidth)
      );
    }
    //  计算浏览器的bar高度
    const barHeight = () => window.outerHeight - window.innerHeight;

    // 屏幕坐标转换为窗口坐标
    const screenToClient = (screenX, screenY) => {
      const clienX = screenX - window.screenX;
      const clienY = screenY - window.screenY - barHeight();
      return [clienX, clienY];
    };

    // 控制当前页面背景
    document.body.style.background =
      new URLSearchParams(window.location.search).get("color") || "#fff";
  </script>
  <script>
    const channel = new BroadcastChannel("myChannel");
    class Ball {
      constructor(size = 3, color = "#000") {
        // 设置尺寸
        this.size = size;
        // 设置颜色
        this.color = color;
        // 创造标签
        this.ballDom = document.createElement("div");
        this.top = 0;
        // 是否反转
        this.revert = false;
      }
      render() {
        this.left = Math.floor(Math.random() * window.innerWidth);
        this.ballDom.style.background = this.color;
        this.ballDom.style.width = this.size * 10 + "px";
        this.ballDom.style.height = this.size * 10 + "px";
        this.ballDom.style.left = this.left + "px";
        this.ballDom.style.top = this.top + "px";
        this.ballDom.style.borderRadius = "50%";
        this.ballDom.style.position = "fixed";
        document.body.appendChild(this.ballDom);
      }
      dropFn(speed = 0.03, destoryFn) {
        let animationId;
        const doSomething = () => {
          // 检测元素超出了窗口
          if (isElementOutViewport(this.ballDom)) {
            destoryFn?.(this.revert); // 销毁时通过this.revert 判断是接小球成功还是失败
            cancelAnimationFrame(animationId);
            this.ballDom.remove();
          }

          this._checkCollision(() => (this.revert = true));
          // 掉转方向
          if (this.revert) {
            this.top -= 1 + speed;
          } else {
            // 改变小球的高度
            this.top += 1 + speed;
          }

          this.ballDom.style.top = this.top + "px";
          // 在任务中再次调用 requestAnimationFrame
          animationId = requestAnimationFrame(doSomething);
        };
        // 启动周期性任务
        animationId = requestAnimationFrame(doSomething);
      }

      // 碰撞检测
      _checkCollision(successFn) {
        const ballTop = this.top + this.size * 10;
        const ballLeft = this.left;
        const ballRight = this.left + this.size * 10;
        if (!window.bounding) return;
        if (
          ballTop >= window.bounding.top &&
          ballLeft >= window.bounding.left &&
          ballRight <= window.bounding.right
        ) {
          successFn?.();
        }
      }
    }

    class Game {
      constructor(nums = 10) {
        this.maxNums = this.nums;
        this.curball = null;
        this.successCount = 0;
        this.failCount = 0;
      }
      generateBalls(
        nums = 1,
        speed = 0.05,
        controlCallbackFn,
        delayTime = 2000
      ) {
        // 每次移动元素或者浏览器都会触发这个回调,将最新的坐标值传入
        controlCallbackFn((bounding) => {
          // 通过传递发现会有更新的延迟,更新时机比较多,直接挂到全局了
          window.bounding = bounding;
        });
        for (let i = 0; i < nums; i++) {
          setTimeout(() => {
            // 生成小球
            this.curball = new Ball();
            // 渲染
            this.curball.render();
            // 下坠
            this.curball.dropFn(speed, (revert) => {
              if (revert) {
                this.successCount++;
                console.log("接小球成功个数", this.successCount);
              } else {
                this.failCount++;
                console.log("接小球失败个数", this.failCount);
              }
              // 销毁一个 再生成一个
              this.generateBalls(1, speed, controlCallbackFn, delayTime);
            });
            // 这里是控制小球的生成产生时间差
          }, i * delayTime);
        }
      }
      start(nums, speed, controlCallbackFn, delayTime) {
        game.generateBalls(nums, speed, controlCallbackFn, delayTime);
      }
      end() {
        document.querySelector(".gameText").innerHTML = "game over";
      }
    }

    // 元素移动消息的回调
    const controlCallbackFn = (cb) => {
      const board = document.querySelector(".board");
      const getBounding = () => {
        const boardBound = board.getBoundingClientRect();
        const targetBound = {
          left: boardBound.left,
          top: boardBound.top,
          right: boardBound.width + boardBound.left,
        };
        return targetBound;
      };
      // 第一次加载先执行一次
      cb(getBounding());
      board.onmousedown = function (e) {
        board.style.cursor = "pointer";
        let x = e.pageX - board.offsetLeft;
        window.onmousemove = function (e) {
          cb(getBounding());
          board.style.left = e.clientX - x + "px";
        };
        window.onmouseup = function () {
          window.onmousemove = null;
          window.onmouseup = null;
          board.style.cursor = "unset";
        };
      };
    };

    // 窗口移动消息的回调
    const controlCallbackFn2 = (cb) => {
      channel.onmessage = (event) => {
        if (event.data) {
          const [clientX, clientY] = screenToClient(event.data.x, event.data.y);
          const targetBound = {
            top: clientY,
            left: clientX,
            right: clientX + event.data.w,
          };
          cb(targetBound);
        }
      };
    };

    const game = new Game();
    /*用元素接小球*/
    game.start(10, 10, controlCallbackFn);
    /*用浏览器接小球*/
    // game.start(3, 10, controlCallbackFn2);

    /*--------浏览器窗口变化发送---------------*/
    let screenX = 0;
    let screenY = 0;
    let screenW = 0;
    // 轮训监听浏览器窗口的变化
    setInterval(() => {
      if (
        screenX !== window.screenX ||
        screenW !== window.outerWidth ||
        screenY !== window.screenY
      ) {
        // 浏览器窗口移动了
        channel.postMessage({
          x: window.screenX,
          y: window.screenY,
          w: window.outerWidth,
        });
        screenX = window.screenX;
        screenY = window.screenY;
        screenW = window.outerWidth;
      }
    }, 100);
    // 监听浏览器缩放操作
    window.addEventListener("resize", function (event) {
      // 在窗口移动时执行的代码
      console.log("窗口位置已改变");
    });
  </script>
</html>