一、前言
上一篇文章通过学习一个元素在不同浏览器标签的拖动,产生变魔术一样的效果,用最简的方式学习了其中的原理,接下来实现一个用浏览器窗口接小球的游戏,仍然是最简的代码实现, 只实现最核心的功能, 比如控制小球个数, 下降速率, 其余的比如成功失败,等游戏逻辑待自行补充完善
二、功能分析
实现浏览器接小球分以下几个步骤
- 先实现单机版本的,同一个标签内实现一个元素接小球,用来调试,道理是一样的
- 设计小球的类、设计游戏的类, 希望可配置小球的数量、控制小球的下降速度、控制小球按一定时间间隔产生,不要一下子出来
- 性能考虑,优化代码
- 实现用另一个浏览器窗口接小球, 需要通讯,坐标转化
- 监听浏览器窗口的移动
难点
窗口静止时仍然可以接小球,小球下降的全阶段,以及浏览器窗口拖动的全过程都可以准确的拿到坐标进行碰撞检测
多小球实现不同小球的不同时间生成,销毁,反转,下降速度控制
抽离功能,分层处理,代码组织, 一套代码实现两种不同类型的接小球
三、 代码实现
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>