最近趁着幻兽帕鲁比较火,刚好准备参加掘金的活动,蹭个热度。
老规矩,先展示一下最终效果(代码在文末):

小游戏比较简单,页面有一只可爱的"龙"随机运动,点击按钮弹出一个有"帕鲁球"的弹窗,当弹窗中的"帕鲁球"完全包裹我们的"龙"时,游戏结束。
游戏涉及到几个知识点:
- 浏览器跨 Tab 通信
- 如何实现龙的随机运动,并在弹出中保证龙卡片位置和页面实时一致
- 如何用 CSS 画一个好看的球
下面我详细讲解一下以上几点。
浏览器跨 Tab 通信
这个知识点是在前一阵这个动画很火的时候学到的:
在 《浏览器跨 Tab 窗口通信原理及应用实践》 中 Coco 大佬介绍了三种实现方案,本文采取了最简单的 localStorage
来实现。
实现思路非常简单,在 A 页面通过 localStorage.setItem
来设置数据, 在 B 页面通过 window.addEventListener('storage', (event) => {...})
来监听数据变化,在 A 和 B 同源的情况下就可以实现跨 Tab 通信了。
简单举个例子:
写一个html 文件来设置数据:
html
<!DOCTYPE html>
<html lang="en">
<body>
<script>
setInterval(() => {
const currentTime = new Date().toLocaleTimeString();
const randomNumber = Math.random();
console.log(`设置数据, 时间 ${currentTime} 随机数 ${randomNumber}`);
localStorage.setItem("currentTime", currentTime);
localStorage.setItem("randomNumber", randomNumber);
}, 1000);
</script>
</body>
</html>
再写一个文件来接受数据:
html
<!DOCTYPE html>
<html lang="en">
<body>
<script>
window.addEventListener("storage", (event) => {
const { key, oldValue, newValue } = event;
console.log(
`接手到storage改变事件, key:${key} 旧值${oldValue} 新值${newValue}`
);
});
</script>
</body>
</html>
通过控制台看下输出可以看到数据传输是被成功接收到了,我们可以获取到设置的 localStorage
的 key
以及对应的值:

龙的随机运动
首先找一张龙的图片,找了好几张,最后保留了和龙年最配的图片:

把元素指定为 fixed
定位,然后指定元素的 top
和 left
就可以让图片动起来了。每隔 1s 随机给元素一个窗口内的任意位置,并通过 transition: 1s;
让元素的运动的时间为 1s,这样就实现了不停随机运动的龙。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>我不吃饼干</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
height: 100%;
}
body {
height: 100%;
border: 20px ridge #bc7a4e;
background: url(./bg.jpeg);
}
.dragon-img {
width: 200px;
height: 200px;
border-radius: 50%;
box-shadow: 2px 2px 6px 0px rgb(0 0 0 / 34%),
-2px 2px 6px 0px rgb(0 0 0 / 6%);
border: 10px solid #ffd56c;
transition: 1s;
position: fixed;
top: 200px;
left: 200px;
z-index: -1;
}
</style>
</head>
<body>
<img class="dragon-img" id="dragonImg" src="./dragon.jpeg" />
<script>
// 生成[x,y]之间的随机数
function getRandomIntBetween(x, y) {
return Math.floor(Math.random() * (y - x + 1)) + x;
}
// 获取图片元素
const dragonImg = document.getElementById("dragonImg");
// 随机运动函数
function move() {
// 根据浏览器大小设置运动的边界
const maxX = window.innerWidth - 220;
const maxY = window.innerHeight - 220;
// 生成随机位置
const x = getRandomIntBetween(20, maxX);
const y = getRandomIntBetween(20, maxY);
// 设置龙的新位置
dragonImg.style.left = `${x}px`;
dragonImg.style.top = `${y}px`;
}
// 每1秒移动一次
const moveTimer = setInterval(move, 1000);
</script>
</body>
</html>
但是我需要给另一个弹窗发送实时位置,如何发送呢?我想的是每 20ms 发送一次当前位置:
js
const sendTimer = setInterval(() => {
const x = dragonImg.style.left;
const y = dragonImg.style.top;
console.log("发送改动位置", `${x},${y}`);
localStorage.setItem("__dragon_move", `${x},${y}`);
}, 20);
让我们看一下效果:

可以看到数据虽然是每 20ms 发送一次,但是每 1s 内发送的是同一个位置,原来我们通过 dragonImg.style.left
和 dragonImg.style.top
取的是我们设置运动的最终位置,而不是当前的实时位置,这样就无法实现两个页面的同步运动了,通过 window.getComputedStyle(dragonImg)
我们可以获取元素的实时位置,现在我们改一下:
js
// 获取图片的实时位置
function getRealTimeLocation() {
const style = window.getComputedStyle(dragonImg);
const x = parseInt(style.left);
const y = parseInt(style.top);
return [x, y];
}
const sendTimer = setInterval(() => {
const [x, y] = getRealTimeLocation();
console.log("发送改动位置", `${x},${y}`);
localStorage.setItem("__dragon_move", `${x},${y}`);
}, 20);
现在可以正确的发送位置了,但是要注意一点,我们希望的是两个页面的龙在屏幕上的位置一样,而不是对应浏览器页面左上角的距离相同,但是两个浏览器Tab页和屏幕的左上角距离是不一样的,所以我们在发送位置的时候还需要考虑浏览器和屏幕的左上角距离。
js
const sendTimer = setInterval(() => {
// 浏览器窗口左上角相对于屏幕左上角的距离
const { screenX, screenY } = window;
const [x, y] = getRealTimeLocation();
// 估算浏览器UI元素的高度 包括了视口的顶部,比如标签栏和工具栏。
const z = outerHeight - innerHeight;
console.log("发送改动位置", `${screenX + x},${screenY + y},${z}`);
localStorage.setItem("__dragon_move", `${screenX + x},${screenY + y},${z}`);
}, 20);
用 CSS 画一个好看的球
我相信这个这个纯 CSS 实现的球对于大部分人还是有难度的,因为我也是抄的。
不过我可以简单讲一下实现原理 XD。
首先画一个球,我们给它加一个径向渐变
css
.ball {
position: relative;
width: 300px;
height: 300px;
border-radius: 100%;
background: radial-gradient(circle at 50% 55%,
rgba(240, 245, 255, 0.9),
rgba(240, 245, 255, 0.9) 40%,
rgba(225, 238, 255, 0.8) 60%,
rgba(43, 130, 255, 0.4));
}

然后通过 before
和 after
两个伪元素加一下高亮样式,原理就是通过径向渐变在合适的位置加上白色的高亮。
现在一个球展示显示完成了。
代码
主页面
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>我不吃饼干</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
height: 100%;
}
body {
height: 100%;
border: 20px ridge #bc7a4e;
background: url(./bg.jpeg);
}
.dragon-img {
width: 200px;
height: 200px;
border-radius: 50%;
box-shadow: 2px 2px 6px 0px rgb(0 0 0 / 34%),
-2px 2px 6px 0px rgb(0 0 0 / 6%);
border: 10px solid #ffd56c;
transition: 1s;
position: fixed;
top: 200px;
left: 200px;
z-index: -1;
}
.open-button {
width: 300px;
height: 100px;
border: none;
border-radius: 50px;
font-size: 36px;
margin: 20px auto;
display: block;
animation: blink 1s infinite;
}
.open-button:active {
transform: scale(0.95);
/* 点击时缩小按钮 */
}
@keyframes blink {
0%,
100% {
background-color: #f00;
/* 按钮初始和结束时的背景颜色 */
color: #fff;
/* 按钮文字颜色 */
box-shadow: 0 0 10px #f00;
/* 外发光效果增加紧迫感 */
}
50% {
background-color: #fff;
/* 按钮中间状态的背景颜色 */
color: #f00;
/* 按钮文字颜色 */
box-shadow: 0 0 20px #f00;
/* 外发光效果更强 */
}
}
</style>
</head>
<body>
<img class="dragon-img" id="dragonImg" src="./dragon.jpeg" />
<button class="open-button" id="openButton" onclick="openBall()">
弹出帕鲁球
</button>
<script>
let openBall = () => {
window.open(
"./ball.html",
"_blank",
"width=315,height=315,menubar=no,toolbar=no,location=no,status=no,resizable=no,scrollbars=no"
);
openButton.style.display = "none";
};
// 生成[x,y]之间的随机数
function getRandomIntBetween(x, y) {
return Math.floor(Math.random() * (y - x + 1)) + x;
}
// 获取图片元素
const dragonImg = document.getElementById("dragonImg");
// 随机运动函数
function move() {
// 根据浏览器大小设置运动的边界
const maxX = window.innerWidth - 220;
const maxY = window.innerHeight - 220;
// 生成随机位置
const x = getRandomIntBetween(20, maxX);
const y = getRandomIntBetween(20, maxY);
// 设置龙的新位置
dragonImg.style.left = `${x}px`;
dragonImg.style.top = `${y}px`;
}
// 获取图片的实时位置
function getRealTimeLocation() {
const style = window.getComputedStyle(dragonImg);
const x = parseInt(style.left);
const y = parseInt(style.top);
return [x, y];
}
// 每1秒移动一次
const moveTimer = setInterval(move, 1000);
const sendTimer = setInterval(() => {
// 浏览器窗口左上角相对于屏幕左上角的距离
const { screenX, screenY } = window;
const [x, y] = getRealTimeLocation();
// 估算浏览器UI元素的高度 包括了视口的顶部,比如标签栏和工具栏。
const z = outerHeight - innerHeight;
console.log("发送改动位置", `${screenX + x},${screenY + y},${z}`);
localStorage.setItem(
"__dragon_move",
`${screenX + x},${screenY + y},${z}`
);
}, 20);
window.addEventListener("storage", (event) => {
const { key, newValue } = event;
if (key === "__dragon_stop") {
console.log("游戏结束");
console.log(dragonImg.style)
const style = window.getComputedStyle(dragonImg);
dragonImg.style.left = style.left;
dragonImg.style.top = style.top;
clearInterval(moveTimer);
clearInterval(sendTimer);
}
});
</script>
</body>
</html>
弹出窗口页 ball.html
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>点击此处拖动帕鲁球</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
height: 100%;
}
body {
height: 100%;
}
.dragon-img {
width: 200px;
height: 200px;
border-radius: 50%;
box-shadow: 2px 2px 6px 0px rgb(0 0 0 / 34%),
-2px 2px 6px 0px rgb(0 0 0 / 6%);
border: 10px solid #ffd56c;
/* transition: 1s; */
position: fixed;
top: 0px;
left: 0px;
z-index: -1;
}
.ball {
position: relative;
width: 300px;
height: 300px;
border-radius: 100%;
background: radial-gradient(
circle at 50% 55%,
rgba(240, 245, 255, 0.9),
rgba(240, 245, 255, 0.9) 40%,
rgba(225, 238, 255, 0.8) 60%,
rgba(43, 130, 255, 0.4)
);
opacity: 0.8;
}
.ball:before {
content: "";
position: absolute;
top: 1%;
left: 5%;
border-radius: 100%;
height: 80%;
width: 40%;
background: radial-gradient(
circle at 130% 130%,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0) 46%,
rgba(255, 255, 255, 0.8) 50%,
rgba(255, 255, 255, 0.8) 58%,
rgba(255, 255, 255, 0) 60%,
rgba(255, 255, 255, 0) 100%
);
transform: translateX(131%) translateY(58%) rotateZ(168deg)
rotateX(10deg);
}
.ball:after {
content: "";
position: absolute;
display: block;
top: 5%;
left: 10%;
width: 80%;
height: 80%;
border-radius: 100%;
z-index: 2;
transform: rotateZ(-30deg);
background: radial-gradient(
circle at 50% 80%,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0) 74%,
white 80%,
white 84%,
rgba(255, 255, 255, 0) 100%
);
}
</style>
</head>
<body>
<div class="ball"></div>
<img class="dragon-img" id="dragonImg" src="./dragon.jpeg" />
<script>
let over = false;
// 计算两个圆心之间的距离
function isCircleACoveringCircleB(xA, yA, rA, xB, yB, rB) {
var distance = Math.sqrt((xA - xB) ** 2 + (yA - yB) ** 2);
console.log(xA, yA, rA, xB, yB, rB, distance);
// 判断圆A的半径是否足够大以覆盖圆B
return rA >= distance + rB;
}
window.addEventListener("storage", (event) => {
if (over) {
return;
}
const { key, newValue } = event;
console.log("接受改动位置", newValue);
if (key === "__dragon_move") {
const [dx, dy, dz] = newValue.split(",");
// 浏览器窗口左上角相对于屏幕左上角的距离
const { screenX, screenY } = window;
const x = dx - screenX;
const y = dy - screenY;
const z = outerHeight - innerHeight;
const left = x;
const top = y + (dz - z);
dragonImg.style.left = `${left}px`;
dragonImg.style.top = `${top}px`;
const cover = isCircleACoveringCircleB(
150,
150,
150,
left + 100,
top + 100,
100
);
if (cover) {
console.log("游戏结束");
over = true;
localStorage.setItem(
"__dragon_stop",
`${screenX + x},${screenY + y}`
);
}
}
});
</script>
</body>
</html>