通过仿制 Google Chrome 的恐龙小游戏,我们可以掌握如下知识点:
- 灵活使用视口单位
- 掌握绝对定位
- JavaScript 来操作 CSS 变量
- requestAnimationFrame 函数的使用
- 无缝动画实现
页面结构
实现页面结构
通过上述的页面结构我们可以知道,此游戏中需要有如下的元素:
- 游戏世界
- 小恐龙
- 分数
- 游戏开始的信息提示
- 地面
- 仙人掌
然后构建对应的页面结构
<div class="world">
<div class="score">0</div>
<div class="start-screen">按任意键开始</div>
<img src="./image/ground.png" class="ground" />
<img src="./image/ground.png" class="ground" />
<img src="./image/dino-stationary.png" class="dino" />
</div>
使用绝对定位完成页面元素的布局
定义好元素后,我们可以编写对应的样式:
.world {
position: relative;
overflow: hidden;
/* 这里我们先使用固定值来设置,随后使用JS来动态设置值 */
width: 100%;
height: 300px;
}
.score {
position: absolute;
top: 1vmin;
right: 1vmin;
font-size: 3vmin;
}
.start-screen {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 3vmin;
}
.hine {
display: none;
}
/* 使用CSS变量进行占位,然后使用JS来控制变量计算 */
.ground {
--left: 0;
position: absolute;
width: 300%;
bottom: 0;
left: calc(var(--left) * 1%);
}
.dino {
--bottom: 0;
position: absolute;
left: 0;
height: 30%;
bottom: calc(var(--bottom) * 1%);
}
.cactus {
--left: 0;
position: absolute;
left: calc(var(--left) * 1%);
height: 30%;
bottom: 0;
}
使用 JS 来监听视口大小改变,从而修改游戏世界元素的宽高
为了能够很好的适配所有的设备,我们需要使用 JS 来监听视口的大小改变,从而动态修改页面的元素大小具体的步骤如下。
首先在游戏世界元素中添加如下属性:
<div class="world" data-world></div>
编写 JS 代码:
const WORLD_WIIDTH = 100;
const WORLD_HEIGHT = 30;
const worldElem = document.querySelector("[data-world]");
setPixelToWorldScale(); // 初始化游戏世界的大小
window.addEventListener("resize", setPixelToWorldScale);
function setPixelToWorldScale() {
let worldToPixeScale = 0;
/**
* 判断视口的大小是否大于我们自定义的常量,这样做主要是保证我们的游戏世界的元素大小在视口的中央
*
* 当视口宽度大于高度时,判断结果为false,就取高度做计算
* 当视口宽度小于高度时,判断结果为true,就取宽度做计算
*/
if (window.innerWidth / window.innerHeight < WORLD_WIIDTH / WORLD_HEIGHT) {
worldToPixeScale = window.innerWidth / WORLD_WIIDTH;
} else {
worldToPixeScale = window.innerHeight / WORLD_HEIGHT;
}
worldElem.style.width = `${WORLD_WIIDTH * worldToPixeScale}px`;
worldElem.style.height = `${WORLD_HEIGHT * worldToPixeScale}px`;
}
使用 requestAnimationFrame 编写对应的更新动画函数
整体的页面布局好以后,我们就可以开始对恐龙、地面、仙人掌和分数进行动画的渲染。但是首先我们先用编写控制动画运行的函数。
我们要编写动画函数的话,可以使用requestAnimationFrame
函数来帮我们实现。
window.requestAnimationFrame()
函数它会告诉浏览器你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。具体的实现代码如下:
let lastTime; // 上一次动画执行时间
function update(time) {
// 动画开始执行时,lastTime是为null,所以需要对齐赋值
if (lastTime == null) {
lastTime = time;
window.requestAnimationFrame(update);
return;
}
const delta = time - lastTime; // 计算上一次动画时间和本次动画时间差
console.log(delta);
lastTime = time;
window.requestAnimationFrame(update);
}
实现地面动画
说明: 以下核心代码中会出现一些辅助函数,为了不让篇幅过长,所以这里就不在展示辅助函数代码,可以在下载完整的代码中进行查看。
实现地面移动的动画其实很简单,因为我们地面的样式是使用绝对定位,并且地面元素有两个,所以只要让两个地面元素交替向左移动就可以实现无缝的动画。
为了提示游戏的难度,游戏会随着时间的推移地面的移动速度会越来越快,具体核心代码如下:
// 地面相关JS
const SPEED = 0.05; // 地面移动量(移动量越大速度越快)
const grounds = document.querySelectorAll("[data-ground]");
// 重置地面位置
export function setupGround() {
setCustomProperty(grounds[0], "--left", 0);
setCustomProperty(grounds[1], "--left", 300);
}
/**
* 修改每帧地面动画
* @param {number} delta 每帧动画的时间差
* @param {number} speedScale 难度系数
*/
export function updateGround(delta, speedScale) {
grounds.forEach((ground) => {
incrementCustomProperty(ground, "--left", delta * speedScale * SPEED * -1); // 向左移动地面
// 当地面元素左移到-300像素时,需要把对应的地面元素重置到第二个地面元素后
if (getCustomProperty(ground, "--left") <= -300) {
// 因为有两个地面元素,所以长度为两个地面元素的长度
incrementCustomProperty(ground, "--left", 600);
}
});
}
// 核心游戏JS
// 动画执行函数
function update(time) {
if (lastTime == null) {
lastTime = time;
window.requestAnimationFrame(update);
return;
}
const delta = time - lastTime;
updateSpeedScale(delta);
updateGround(delta, speedScale);
lastTime = time;
window.requestAnimationFrame(update);
}
// 修改游戏难度系数(随着时间的推移难度系数值越大,地面的移动速度越快)
function updateSpeedScale(delta) {
speedScale += delta * SPEED_SCALE_INCREASE;
}
实现小恐龙相关动画
实现小恐龙关键帧替换
在这个游戏中我们的小恐龙是由两个关键帧交替实现动画效果的,所以我们需要在一帧动画期间交替替换小恐龙的两个关键帧,从而绘制小恐龙跑步的动画,具体核心代码如下:
/**
* 修改小恐龙的动画
* @param {number} delta 每帧动画的时间差
* @param {number} speedScale 难度系数
*/
export function updateDino(delta, speedScale) {
handleRun(delta, speedScale);
}
/**
* 小恐龙运动动画
* @param {number} delta 每帧动画的时间差
* @param {number} speedScale 难度系数
* @returns
*/
function handleRun(delta, speedScale) {
// 判断小恐龙是否跳起,跳起的话关键帧只能固定一个
if (isJumping) {
dinoElem.src = `./imgs/dino-stationary.png`;
return;
}
/**
* 因为一帧动画可以执行很多次,所以我们需要对每帧执行完成后交替更换小恐龙的关键帧图片
* FRAME_TIME用于每帧小恐龙交替拆分的关键值
*/
if (currentFrameTime >= FRAME_TIME) {
dinoFrame = (dinoFrame + 1) % DINO_FRAME_COUNT;
dinoElem.src = `./imgs/dino-run-${dinoFrame}.png`;
currentFrameTime -= FRAME_TIME;
}
currentFrameTime += delta * speedScale;
}
实现小恐龙跳起动画
小恐龙的跳起主要是在 Y 轴上进行上下运动,并且为了达到最好的动画效果,我们会声明两个变量用于控制跳起动画的效果。具体核心代码如下:
/**
* 跳起动画
* @param {number} delta 每帧动画的时间差,
* @returns 小恐龙跳起跳起的高度
*/
function handleJump(delta) {
if (!isJumping) return;
incrementCustomProperty(dinoElem, "--bottom", yVelocity * delta);
// 接触到地面后重置相关参数
if (getCustomProperty(dinoElem, "--bottom") <= 0) {
setCustomProperty(dinoElem, "--bottom", 0);
isJumping = false;
}
yVelocity -= GRAVITY * delta;
}
// 监听小恐龙跳起事件
function onJump(e) {
if (e.code !== "Space" || isJumping) return;
yVelocity = JUMP_SPEED;
isJumping = true;
}
实现仙人掌动画
仙人掌是在一定时间间隔内在游戏世界中创建出来,并且动画移动效果跟地面一样。所以我们在实现此功能的时候最核心的业务就是在随机间隔内生成对应的仙人掌,并执行相应的动画。具体的核心代码如下:
// 创建仙人掌
function createCactus() {
const cactus = document.createElement("img");
cactus.dataset.cactus = true;
cactus.src = "./imgs/cactus.png";
cactus.classList.add("cactus");
setCustomProperty(cactus, "--left", 100);
worldElem.append(cactus);
}
// 生成仙人掌并执行相应动画
export function updateCactus(delta, speedScale) {
document.querySelectorAll("[data-cactus]").forEach((cactus) => {
incrementCustomProperty(cactus, "--left", delta * speedScale * SPEED * -1);
if (getCustomProperty(cactus, "--left") <= -100) {
cactus.remove();
}
});
// 判断是否要生成下一个仙人掌
if (nextCactusTime <= 0) {
createCactus();
// 生成下一个仙人掌的时间间隔
nextCactusTime =
randomNumberBetween(CACTUS_INTERVAL_MIN, CACTUS_INTERVAL_MAX) /
speedScale;
}
nextCactusTime -= delta;
}
// 重置仙人掌
export function setupCactus() {
nextCactusTime = CACTUS_INTERVAL_MIN;
// 移除是有仙人掌
document.querySelectorAll("[data-cactus]").forEach((cactus) => {
cactus.remove();
});
}
游戏结束评定
游戏的结束判断就是小恐龙是否碰到仙人掌,所以我们首先需要添加获取小恐龙和仙人掌的方法,具体函数如下:
// 获取仙人掌
export function getCactusRects() {
return [...document.querySelectorAll("[data-cactus]")].map((cactus) => {
return cactus.getBoundingClientRect();
});
}
// 获取小恐龙
export function getDinoRect() {
return dinoElem.getBoundingClientRect();
}
// 判断是否游戏结束
function checkLose() {
const dinoRect = getDinoRect();
return getCactusRects().some((rect) => isCollision(rect, dinoRect));
}
// 通过判断小恐龙和仙人掌是否碰撞
function isCollision(rect1, rect2) {
return (
rect1.left < rect2.right &&
rect1.top < rect2.bottom &&
rect1.right > rect2.left &&
rect1.bottom > rect2.top
);
}
完整代码下载
完整代码下载