俄罗斯方块是一款经典小游戏。通过从零开发一款小游戏,我们可以更好地了解开发使用的语言,同时也得到更强烈的“所见即所得”的反馈,所以开发小游戏会是不错的练手项目。

游戏动态展示

从一个主力语言是JavaScript的开发者角度来分析俄罗斯方块,不同形状的方块的下落、移动、消除其实是 复杂的状态管理和组件通信。这不刚好就是React和Redux的射程范围嘛!(并没有说它们适合游戏开发)

我将通过这篇博文拆解实现俄罗斯方块的一些核心逻辑,以及Redux Tool Kit的使用。

你可以在我的Github仓库上找到项目代码,也可以通过Github Page来试玩这个游戏。

本文涉及的技术

  • JavaScript数组高阶函数
  • 使用Redux Tool Kit对React单页面项目进行状态管理
  • 使用requestAnimationFrame实现动画效果

文件结构

项目总结构

|-- src
    |-- App.js
	|-- index.js
	|-- store.js
	|-- features/
		|-- game/
			|-- components/
			|-- game-slice.js
	|-- styles/
	|-- utils/

我是通过create-react-app创建的项目,游戏界面及功能所有代码都在src路径下:

  • 我保留了index.jsApp.js,同时添加了store.js来处理Redux store的配置代码;
  • 样式全部放在了styles文件夹中,游戏运行的核心逻辑放到了utils文件夹中;
  • Redux官方推荐在使用RTK(Redux Tool Kit)时,创建一个features目录,并把不同的功能模块放在这个目录下,每一个功能模块包含自己的组件、reducer和其他相关文件(如与后端通信的代码),所以我在features目录下创建了game文件夹,并在其下创建了components目录和game-slice.js

组件结构

我规划的组件包括:

|-- components/
	|-- Board.js
	|-- Control.js
	|-- MessagePopup.js
	|-- NextBlock.js
	|-- ScoreBoard.js
	|-- Square.js

组件展示

  • Board.js是主游戏区
  • Control.js是按钮控制面板
  • MessagePopup.js是游戏结束时弹出的消息框
  • NextBlock.js是下一个形状提示框
  • ScoreBoard.js显示当前分数和历史最高分数
  • Square.js是组成主游戏区和提示框的小方块

核心UI组件搭建的逻辑

游戏区域的实现

主游戏区框架的实现

10*18网格
将主游戏区看作一个10*18的网格,可以通过单项数据为0的二维数组实现:

//src/utils/index.js

const boardDefault = () => {
    const rows = 18; //共18行
	const cols = 10; //共10列	
	const array = Array.from(Array(rows), () => Array(cols).fill(0));
	return array;
};

注:在示意图中,我标注了x和y象限,因为之后会使用x和y的值来判断形状在主游戏区的位置。

形状的实现

既然主游戏区可以通过数组来实现,那么不同形状的方块也可以通过数组来实现:

设定每一种形状都用一个4*4的网格作为容器:

[

	[0, 0, 0, 0],
	[0, 0, 0, 0],
	[0, 0, 0, 0],
	[0, 0, 0, 0],

],

在俄罗斯方块游戏中,不同的形状的颜色不同,所以可以用整数1,2,3,4...来区分它们。

使用整数的另一个好处是可以和样式绑定,先提前设置好每个颜色对应的编号:

:root {
	--color-0: #282c34;
	--color-1: #ff6600;
	--color-2: #eec900;
	...
	--color-7: #ff0000;
}

.color-0 {
	background-color: var(--color-0);
}

.color-1 {
	background-color: var(--color-1);
}

...
  
.color-7 {
	background-color: var(--color-7);
}

然后就可以通过数字1,2,3,4...在前文的4*4网格数组中的排列来表达不同的形状了,需要注意的是每个形状还有旋转后的样式也要考虑进去。

长条形状

比方说一个长条形状及它旋转后可以表示为:

//I

[
	[
		[0, 0, 0, 0],
		[1, 1, 1, 1],
		[0, 0, 0, 0],
		[0, 0, 0, 0],
	],

	[
		[0, 1, 0, 0],
		[0, 1, 0, 0],
		[0, 1, 0, 0],
		[0, 1, 0, 0],
	],
],
  • 把单个形状及其旋转后的样式放在同一个数组中,引用时就可以通过索引号来获得形状的旋转效果。
  • 再把所有形状的数组汇总到一个数组中,这样通过索引号就把表示颜色的数字和形状挂钩了。

形状到主游戏区的映射

我们完成了主游戏区和形状分别的实现,那么当游戏进行时,如何把形状映射到主游戏区呢?

在React中,每一次渲染都相当于一次快照,那么就可以把游戏进行时,形状在主游戏区域的每一次移动都看作独立的一次形状数组到主游戏区数组的映射

快照
快照数组

具像化我们映射过程就是从以上第一幅图到第二幅图。

代码实现如下:

//src/features/game/components/Board.js

//这里的board变量是10*18的由0组成的二位数组
const boardSquare = board.map((rowArray, row) => {
	return rowArray.map((square, col) => {
	//col(0-9)row(0-17)
	const blockX = col - x;
	const blockY = row - y;
	//square为0
	let color = square;
	//生成移动的block的颜色
	if (
		blockX >= 0 &&
		blockX < block.length &&
		blockY >= 0 &&
		blockY < block.length
	) {
    //这里的block变量引用了从函数外部取得的表示形状的数组
		color = block[blockY][blockX] === 0 ? color : blockColor;
	}
	//生成key
	const k = row * board[0].length + col;
	return <Square key={k} color={color} />;
	});
});

让我们看看上面代码发生了什么:

  • x,y是形状起始的横轴(行)和纵轴(列)的坐标,我设定的初始值为x=4, y=-5,这样每一个形状的初始位置就位于主游戏区差不多正上方,随着形状的移动,这两个值也会发生变化;
  • boardSquare函数实际上做了两件事:将我们用数组表达的10*8主游戏区展示到UI;根据主游戏区纵坐标(0-17)、横坐标(0-9)与xy的差值来定位,将形状数组映射到主游戏区;
  • 首先通过一组嵌套结构的Array.map()的方法,遍历主游戏区所有网格,并将所有网格的color设置为0
  • 在遍历主游戏区的同时,通过blockX和blockY坐标来锁定位于形状数组内部的方块,如果它的值不为0的话,就返回对应数字的颜色;
  • 因为React要求遍历的组件包含一个独一无二的key,所以通过row * board[0].length + col生成key。

判断边界的逻辑

移动范围边界

形状在主游戏区的移动遵守一定的规则:

  1. 左右下边都不可越界
  2. 如果在主游戏区碰到其他的形状,也不得移动。

我们只用检查形状内部每一个方块是否符合以上规则,就可以判断形状是否可以移动了。

此处还需要注意的是,每一形状都被放置在4*4的网格中,所以要排除方块值为0情况:

//src/utils/index.js

const canMoveTo = (shape, board, x, y, rotation) => {
	const currentShape = shapes[shape][rotation];
	for (let row = 0; row < currentShape.length; row++) {
		for (let col = 0; col < currentShape[row].length; col++) {
			if (currentShape[row][col] !== 0) {
				const proposedX = col + x;
				const proposedY = row + y;
			if (proposedY < 0) {
				continue;
				}
			const possibleRow = board[proposedY];
			if (possibleRow) {
				if (
					possibleRow[proposedX] === undefined ||
					possibleRow[proposedX] !== 0
					) {
						return false;
					}
				} else {
				//超越底线
						return false;
					}
				}
			}
		}
	//默认可以移动
	return true;
};
  • 使用canMoveTo函数来判断是否可以移动,函数接受shape(形状数组)、board(主游戏区数组)、x,y(形状定位坐标)、rotation(旋转索引号)作为参数,返回布尔值。
  • 首先默认可以移动;
  • 利用两个嵌套Array.map()遍历形状数组内部的方块,如果方块的值为非0,就根据xy的值来判断方块位于游戏主区的位置,即proposedXproposedY
  • 因为形状的初始情况是位于游戏主区上方,不在主区内,所以存在proposedY小于0的情况,这个时候继续遍历就好;
  • 根据propsedY可以推断出方块位于主游戏区的行数possibleRow,如果不存在possibleRow的话,则说明方块已经超越了主游戏区的底边,返回false
  • possibleRow[proposedX]来定位形状方块位于主游戏区该行的哪一个方块,如果值不为0或者为undefined的话,说明主游戏区上已经有其他形状的方块或者超越主游戏区左右边界,返回false

消除条件

当主游戏区一整行填满形状方块就会消除,放在数组的框架下思考,就是检查一行是否全不为0。如果符合条件就删除一整行,然后在数组最开始重新添加一行:

//src/utils/index.js

const checkRows = (board) => {
	for (let row = 0; row < board.length; row++) {
	//检查是否一整行都不为'0'
		if (board[row].indexOf(0) === -1) {
		//如果是,删除这一行
				board.splice(row, 1); 
				//同时在数组最开始添加一行新的数组
				board.unshift(Array(10).fill(0));
			}
	}
}

状态管理

使用Redux Tool Kit

在这个应用中,我使用Redux Tool Kit(后文简称RTK)来实现状态管理。

RTK对比传统的Redux来说的优势在于:

  1. 更少的样板代码
  2. RTK通过Immer库来实现不可变数据,提高应用程序的性能。

创建Slice

在RTK中,reducer和action集合到slice中,可以通过引用createSlice来实现:

//src/features/game/game-slice.js

import {createSlice} from '@reduxjs/toolkit'

const gameSlice = createSlice({
	name:'game',
	initialState: {...},
	reducer: {
		rotate:(state, action) =>{
			...
			},
		moveRight: (state, action) =>{
			...
			},
			...
		},
})

export const {rotate, moveRight} = gameSlice.actions
export default gameSlice.reducer;

注:在传统方式中若要修改不可变数据,需要手动复制原始数据,并在副本上进行修改,由于immer库的存在,在编写reducer的时候,可以直接修改state,详细代码可以查看github仓库。

创建store

//src/store.js

import { configureStore } from '@reduxjs/toolkit';
import gameReducer from './features/game/game-slice';

export const store = configureStore({ reducer: { game: gameReducer}})

将数据提供给应用

//src/index.js

import { Provider } from 'react-redux';
import { store } from './store';

const root = ReactDOM.createRoot(document.getElementById('root'));

root.render(
	<React.StrictMode>
		<Provider store={store}>
			<App />
		</Provider>
	</React.StrictMode>
);

使用数据

//src/features/game/components/Control.js

import { useSelector, useDispatch } from 'react-redux';
import { rotate } from '../game-slice';

export default function Controls() {
	const dispatch = useDispatch();
	const isRunning = useSelector((state) => state.game.isRunning);
    const gameOver = useSelector((state) => state.game.gameOver);

	const handleRotate = () => {
		if (!isRunning || gameOver) return;
		dispatch(rotate());
	};

	return (
		<div className="controls">
		{/* rotation */}
			<button
				disabled={!isRunning || gameOver}
				className="control-button"
				onClick={handleRotate}
			>						
			 Rotate
			</button>

下落动画的实现

Delta Time

Delta time(Δt)是一个计算两个时间点之间经过的时间差的量。在游戏开发中,delta time 指的是每一帧之间的时间间隔。

实现方块的下落动画,会运用到delta time

这里我们需要计算两次调用requestAnimationFrame的时间差的值,如果累计差值比我们设定的游戏速度要大,就触发moveDownaction。

形状的下落实际上就是在一定时间间隔下更新画面(触发moveDown这个action),不就是定时器嘛!

使用requestAnimationFrameuseRefuseEffect实现定时器的原因

  • requestAnimationFrame

一般想到定时器会想到setIntervalsetTimeout,但实际上使用requestAnimationFrame的性能更好。

这是因为requestAnimationFrame更符合屏幕的刷新率,通常屏幕的刷新率是60Hz,也就意味着浏览器每间隔16.7毫秒(1000毫秒/60)就会更新一次页面。requestAnimationFrame的回调函数会在浏览器准备好下一帧时调用,从而确保每一帧都能够在屏幕刷新之前完成绘制。

setTimeoutsetInterval的时间并不精准,可能会出现卡顿、延迟等性能问题。

  • useRef

React中的函数组件就像一个快照,每次渲染完成后值就会丢失。所以如果需要让函数“记住”值的话,就需要使用额外的钩子。

在俄罗斯方块游戏中,我们需要对形状下落的帧进行记录,这个值不受组件的生命周期影响,因此需要useRef

  • useEffect

游戏中只需要在特定时间调用控制下落的函数,并且这个过程是Board.js组件的一个副作用,因此需要使用useEffect

//src/utils/useTimer.js

import { useEffect, useRef } from 'react';
import { useDispatch } from 'react-redux';

export function useTimer(flagVal, benchmark, func) {
	const requestRef = useRef();
	const lastUpdateTimeRef = useRef(0);
	const progressTimeRef = useRef(0);
	
	const dispatch = useDispatch();

	const update = (time) => {
		requestRef.current = requestAnimationFrame(update);
		if (!flagVal) return;
		if (!lastUpdateTimeRef.current) {
			lastUpdateTimeRef.current = time;
		}
	
		const deltaTime = time - lastUpdateTimeRef.current;
		progressTimeRef.current += deltaTime;
	
		if (progressTimeRef.current > benchmark) {
		dispatch(func());
		progressTimeRef.current = 0;
		}
		lastUpdateTimeRef.current = time;
	};

	useEffect(() => {
		requestRef.current = requestAnimationFrame(update);
		return () => cancelAnimationFrame(requestRef.current);
	}, [flagVal]);
}
  • 使用useRef创建了三个引用变量requestReflastUpdateTimeRefprogressTimeRef,用于存储定时器的状态和进度。
  • requestRef用于存储当前动画帧的引用, lastUpdateTimeRef用于存储上一次更新的时间戳,progressTimeRef用于存储已经过去的时间。
  • update函数中实现定时器逻辑。
  • 首先使用requestAnimationFrame来获取下一帧的引用,并存储到requestRef.current中,以实现流畅的动画效果;
  • 然后通过flagVal的值,来决定是否退出函数;
  • 接着如果没有lastUpdateTimeRef.current的值,就将当前的时间戳(time)存储到里面。
  • 创建deltaTime,它的值为当前时间戳和lastUpdateTimeRef.current的时间差,将每次更新得到的deltaTime都添加到progressTimeRef.current中,记录累计过去的时间;
  • 如果这个累计值大于我们设定的时间间隔benchmark,就执行传入的函数,执行完毕后,将累计时间清零;
  • 最后将当前时间戳存储在lastUpdateTimeRef.current中,以便下一次调用时使用。
  • 在使用这个定时器时,我们传入应用的状态属性isRunning作为flagVal来判断是否需要退出函数,speed作为benchmark来和累积值做对比,控制形状的下移动画的速度,moveDown作为func来实现方块下移的动作。

总结

本项目通过数组实现了基本的游戏静态画面,动画部分使用了requestAnimationFrame,游戏复杂的状态管理借助了更简洁的RTK。

如果你仔细观察项目代码,会发现我还使用了第三方库Redux Persist来存储最高分数。

如果你想要动手实验一下我在文章中提到的逻辑和状态管理,我准备了starter仓库,它包含了基础的框架和样式,你可以在此基础上搭建属于你的俄罗斯方块,当然你也可以在我的项目基础上添加新的内容,如不同的关卡(下落速度不同),将按键和键盘绑定等。

希望阅读这篇文章让你有所收获,祝你实验愉快!

代码仓库