俄罗斯方块是一款经典小游戏。通过从零开发一款小游戏,我们可以更好地了解开发使用的语言,同时也得到更强烈的“所见即所得”的反馈,所以开发小游戏会是不错的练手项目。
从一个主力语言是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.js
和App.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的网格,可以通过单项数据为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)与x
、y
的差值来定位,将形状数组映射到主游戏区;- 首先通过一组嵌套结构的
Array.map()
的方法,遍历主游戏区所有网格,并将所有网格的color
设置为0
; - 在遍历主游戏区的同时,通过blockX和blockY坐标来锁定位于形状数组内部的方块,如果它的值不为
0
的话,就返回对应数字的颜色; - 因为React要求遍历的组件包含一个独一无二的
key
,所以通过row * board[0].length + col
生成key。
判断边界的逻辑
移动范围边界
形状在主游戏区的移动遵守一定的规则:
- 左右下边都不可越界
- 如果在主游戏区碰到其他的形状,也不得移动。
我们只用检查形状内部每一个方块是否符合以上规则,就可以判断形状是否可以移动了。
此处还需要注意的是,每一形状都被放置在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
,就根据x
和y
的值来判断方块位于游戏主区的位置,即proposedX
和proposedY
; - 因为形状的初始情况是位于游戏主区上方,不在主区内,所以存在
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来说的优势在于:
- 更少的样板代码
- 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
的时间差的值,如果累计差值比我们设定的游戏速度要大,就触发moveDown
action。
形状的下落实际上就是在一定时间间隔下更新画面(触发moveDown这个action),不就是定时器嘛!
使用requestAnimationFrame
、useRef
和useEffect
实现定时器的原因
requestAnimationFrame
一般想到定时器会想到setInterval
和setTimeout
,但实际上使用requestAnimationFrame
的性能更好。
这是因为requestAnimationFrame
更符合屏幕的刷新率,通常屏幕的刷新率是60Hz,也就意味着浏览器每间隔16.7毫秒(1000毫秒/60)就会更新一次页面。requestAnimationFrame
的回调函数会在浏览器准备好下一帧时调用,从而确保每一帧都能够在屏幕刷新之前完成绘制。
但setTimeout
和setInterval
的时间并不精准,可能会出现卡顿、延迟等性能问题。
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
创建了三个引用变量requestRef
、lastUpdateTimeRef
和progressTimeRef
,用于存储定时器的状态和进度。 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仓库,它包含了基础的框架和样式,你可以在此基础上搭建属于你的俄罗斯方块,当然你也可以在我的项目基础上添加新的内容,如不同的关卡(下落速度不同),将按键和键盘绑定等。
希望阅读这篇文章让你有所收获,祝你实验愉快!