某天下班回家的路上闲来无事,突然想到可以自己做一些经典小游戏的项目。在不查资料的情况下,自行构思代码结构、抽象模型。俄罗斯方块虽然是一个比较简单的游戏,但如果想把每个逻辑要点都分析清楚还是需要一些时间的。本文将带你跟随我的设计思路,从零开始,用面向对象的方式完成俄罗斯方块小游戏的模型设计,并使用 React 进行视图层渲染。
模型抽象
所谓模型,就是纯粹的 JS 对象,与渲染层无关,完全关心数据。对于俄罗斯方块游戏来说,要素无非就是:方块形状、游戏棋盘、游戏规则。我们分别来看。
如何抽象方块形状
形状的外观数据
俄罗斯方块的基本形状有 7 个,分别是 L、O、I、Z、T 及其镜像(L、Z 有镜像,其余形状镜像为自身)。基本形状在棋盘上运动时可以左右移动和旋转。我们只要用数据表示出这些形状,这一步就完成了。
不难看出,每个形状都由四个小方块组成,而小方块的相对位置决定了形状。我们自然会想到用一个二维坐标来表示一个小方块。就像这样:
对于形状旋转,一开始我想用坐标原点作为旋转中心直接进行旋转后的坐标计算。但发现像上图中 Z 这个形状,其旋转中心在 (0, 0.5),并没有落在一个小方块上。如果将旋转坐标纳入模型范畴,坐标系就会出现小数,导致计算变得复杂。这里简单起见,我们可以直接定义形状在旋转 0°、90°、180° 和 270° 时的坐标。另外,由于每个形状都必然包括 (0, 0) 坐标,可以将其在向量中省略:
// 源码为 TypeScript 实现,如不熟悉可以忽略类型定义部分,主体逻辑与 JS 相同。
export type ShapeCoordinate = [number, number];
export interface ShapeOptions {
type: string;
vectors: Record<ShapeRotate, Array<ShapeCoordinate>>;
}
export const Z: ShapeOptions = {
type: 'z',
vectors: {
0: [
[-1, 0],
[0, 1],
[1, 1],
],
90: [
[0, -1],
[-1, 0],
[-1, 1],
],
180: [
[-1, 0],
[0, 1],
[1, 1],
],
270: [
[0, -1],
[-1, 0],
[-1, 1],
],
},
};
我们将这组数据称为:形状的外观数据。有了外观的抽象方法后,其余形状也按照此规则进行定义即可。
形状的状态数据
仅有外观数据来描述形状长什么样子还不够,我们还要把形状放在棋盘,描述它在游戏棋盘中状态,同时赋予形状移动、旋转等能力。
在俄罗斯方块游戏中,形状可以左右移动、向下移动、旋转。而由这些动作出发,我们可以思考形状在执行这些动作后,哪些状态发生了改变。从而得出结论:形状在棋盘中移动后,坐标和旋转角度会发生变化。这就是形状的状态数据。
形状在棋盘中的坐标定义方法后续会详细说明。
形状类
形状的状态数据和外观数据合在一起,构成了形状类的基本属性。其中外观数据为只读数据,状态数据会被形状动作更改。而形状动作其实就是形状类的方法,我们可以定义形状的向左移动、向右移动、向下移动、旋转方法:
export class Shape {
id = generate();
/** 用于直观描述形状外观 */
type: string;
coordinate: ShapeCoordinate;
private vectors: ShapeOptions['vectors'];
private rotate: ShapeRotate = 0;
/** 考虑旋转后的图形真实向量数据 */
get vector() {
return this.vectors[this.rotate];
}
/** 图形在棋盘上真实占据的坐标点位 */
get points() {
const points: ShapeCoordinate[] = this.vector.map((i) => [i[0] + this.coordinate[0], i[1] + this.coordinate[1]]);
points.push(this.coordinate);
return points;
}
constructor(private options: ShapeOptions, initialCoordinate: ShapeCoordinate) {
this.vectors = options.vectors;
this.type = options.type;
this.coordinate = initialCoordinate;
}
clockWiseRotate() {
this.rotate = ((this.rotate + 90) % 360) as ShapeRotate;
return this;
}
moveTo(coordinate: ShapeCoordinate) {
this.coordinate = coordinate;
}
moveDown() {
this.coordinate[1] += 1;
return this;
}
moveLeft() {
this.coordinate[0] -= 1;
return this;
}
moveRight() {
this.coordinate[0] += 1;
return this;
}
clone() {
const shape = new Shape(this.options, [...this.coordinate]);
shape.rotate = this.rotate;
return shape;
}
}
如何抽象棋盘格
棋盘的坐标定义
棋盘格其实就是一个二维坐标系,这里我们按照浏览器习惯,将原点置于左上角,并用一个二维数组表示。为了方便清除行的操作,我们可以将二维数组的第一个索引定义为行,第二个索引定义为列,从而定义出棋盘格数组。注意这里数组的第一个索引是坐标的中的 “列”。通过坐标取出棋盘中的某个点的方法为:
getPoint(coordinate: [number, number]) {
return this.playground[coordinate[1]][coordinate[0]];
}
例如下图中,红色形状左上角方块在棋盘中的坐标为 (3, 13)。通过数组下标取值的方法为 playground[13, 3]。当然,你也可以按照你喜欢的方式定义 playground,只要和渲染层约定一致都是可以的。
棋盘格的状态
俄罗斯方块游戏中,当形状触底后便无法移动。所以,在棋盘上的形状的在移动的形状本质是不同的。我们可以把形状从可移动状态变为固定状态的过程称为落盘。落盘后,形状便失去了其状态和行为,变为棋盘格上的一组位置。简单起见,我们假设落盘后也不需要考虑颜色。那么每个格子点位就只有两种状态:被占据和不被占据。我们可以用两个常量直接表示这两种状态,并填充在二维数组内。运动中的形状则作为一个形状类单独管理。
const EMPTY_POINT = Symbol('empty');
const FILLED_POINT = Symbol('filled');
type EmptyPointType = typeof EMPTY_POINT;
type FilledPointType = typeof FILLED_POINT;
type GroundPoint = FilledPointType | EmptyPointType;
棋盘格类
形状会在棋盘内移动或旋转。若形状在经过一次操作后超过边界,或与其它占据位置的格子重叠,则此次操作将无法完成。为此,我们可以定义一些工具方法,用于判断形状是否越界。同样简单起见,我们可以首先判断某个坐标是否越界,若形状中的每一个坐标都未越界,则此形状未越界。
注意代码中的可落盘,代表的是形状在游戏运行过程中处于一个合法的位置,并不代表真的要在这里落盘。而游戏中当一个形状刚刚诞生时,是处于上边界外的。为了云讯这种状态存在,这里我们用了两个方法来判断形状是否可落盘,即isPointOutOfSide 和 isPointEmpty。通过判断形状是否超出左右边界、形状是否和棋盘中已填充的色块重叠,来判断位置是否合法。
由于方块填满一行后,当前行需要被清除,我们可以再定义一个方法 isRowFilled 用于判断某一行是否被填满。而清除行的操作则分为两步:首先将此行从二维数组中移除,随后在数组顶端重新创建一个空行即可。
export interface GroundOptions {
size: [number, number];
playground?: GroundPoint[][];
}
export class Ground {
size: GroundOptions['size'];
playground: GroundPoint[][];
get initialShapeCoordinate(): ShapeCoordinate {
return [Math.round(this.size[0] / 2), -1];
}
constructor(options: GroundOptions) {
this.size = options.size;
this.playground = options.playground ?? this.createGround();
}
/** true if out of ground */
isPointEmpty(point: ShapeCoordinate) {
if (this.isPointOutOfGround(point)) return true;
return this.playground[point[1]][point[0]] === EMPTY_POINT;
}
private isRowFilled(row: GroundPoint[]) {
return row.every((i) => i !== EMPTY_POINT);
}
private createRow(): GroundPoint[] {
return new Array(this.size[0]).fill(EMPTY_POINT);
}
private createGround() {
return new Array(this.size[1]).fill(this.createRow());
}
private clearRow(rowIndex: number) {
this.playground.splice(rowIndex, 1);
this.playground.unshift(this.createRow());
}
clear() {
this.playground = this.createGround();
}
isPointOutOfTop(point: ShapeCoordinate) {
return point[1] < 0;
}
isPointOutOfSide(point: ShapeCoordinate) {
return point[0] < 0 || point[0] >= this.size[0] || point[1] >= this.size[1];
}
isPointOutOfGround(point: ShapeCoordinate) {
return this.isPointOutOfSide(point) || this.isPointOutOfTop(point);
}
fillPoint(coordinate: ShapeCoordinate, point: GroundPoint) {
if (this.isPointOutOfTop(coordinate) || this.isPointOutOfSide(coordinate)) return;
this.playground[coordinate[1]][coordinate[0]] = point;
}
isShapeDroppable(shape: Shape) {
return shape.points.every((p) => !this.isPointOutOfSide(p) && this.isPointEmpty(p));
}
isShapeOutOfTop(shape: Shape) {
return shape.points.some((p) => this.isPointOutOfTop(p));
}
isShapeOutOfSide(shape: Shape) {
return shape.points.some((p) => this.isPointOutOfSide(p));
}
dropShape(shape: Shape) {
shape.points.forEach((p) => this.fillPoint(p, FILLED_POINT));
}
clearFilledRow() {
this.playground.forEach((row, index) => {
if (this.isRowFilled(row)) this.clearRow(index);
});
}
clone() {
const playground = [...this.playground.map((i) => [...i])];
return new Ground({ size: this.size, playground });
}
toPoints() {
const points: Array<ShapeCoordinate> = [];
this.playground.forEach((row, rowIndex) => {
row.forEach((p, colIndex) => points.push([colIndex, rowIndex]));
});
return points;
}
}
如何让游戏跑起来
到目前为止,我们有了形状类和棋盘类,但他们只是单独的 class,还没有产生联系。在本节,我们来分析一下如何让形状和棋盘联动起来。
上文中也提到,形状在落盘前处于活跃状态,而且不难看出,游戏中同一时间只有一个形状处于活跃状态,而且整个游戏生命周期内,都是围绕活跃图形展开的。因此,我们可以把活跃的形状单独拿出来,在模型中单独赋予一个形状实例对象。当用户操作形状移动或旋转时,判断一下这个操作是否合法,如果不合法,就忽略此次操作;当一个计时周期结束时,将形状自动下移一个格子即可。
处理用户操作
在形状类中已经包含了移动和旋转方法,我们只需要再多判断一次操作合法性即可。这里我使用了一个比较简单的方法:先假装将用户操作执行,判断执行后的形状位置是否合法,如果发现操作不会造成越界或者重叠,再真正执行此操作:
rotate() {
const virtualShape = this.activeShape.clone().clockWiseRotate();
if (!this.ground.isShapeDroppable(virtualShape)) return;
this.activeShape.clockWiseRotate();
}
moveLeft() {
const virtualShape = this.activeShape.clone().moveLeft();
if (!this.ground.isShapeDroppable(virtualShape)) return;
this.activeShape.moveLeft();
}
moveRight() {
const virtualShape = this.activeShape.clone().moveRight();
if (!this.ground.isShapeDroppable(virtualShape)) return;
this.activeShape.moveRight();
}
自动下移形状
在游戏中,活跃形状每隔一定一段时间会自动向下移动一格。这个时间节点很关键:
- 如果形状无法继续下移,将触发形状落盘,棋盘对应位置被填充。
- 落盘后,将棋盘中被填满的行清除
- 若形状无法继续下移,但自身又有一部分超出了上边界,则游戏结束
我们可以定义一个 moveDown 方法,用于判断以上几种情况:
private moveDown() {
const virtualShape = this.activeShape.clone().moveDown();
const droppable = this.ground.isShapeDroppable(virtualShape);
if (!droppable && this.ground.isShapeOutOfTop(this.activeShape)) this.end();
else if (!droppable) this.dropActiveShape();
else this.activeShape.moveDown();
}
private dropActiveShape() {
this.ground.dropShape(this.activeShape);
this.ground.clearFilledRow();
this.setActiveShape(this.randomCreateShape());
}
随机生成形状
随机比较好实现,我们只要通过 JS 随机数在已定义好的几个形状外观中选择一个即可。重点是形状诞生的位置。为了保证每个形状在诞生之初都在棋盘外,在随机出一个形状微观后,我们可以算一下 y 轴方向上需要的偏移量,将生成的形状移到对应位置:
randomCreateShape() {
const index = Math.floor(Math.random() * this.shapeTypes.length);
const shapeOptions = this.shapeTypes[index];
const shape = new Shape(shapeOptions, this.ground.initialShapeCoordinate);
const deltaY = Math.max(...shape.vector.map((i) => i[1]));
shape.moveTo([shape.coordinate[0], shape.coordinate[1] - deltaY]);
return shape;
}
视图渲染
模型定义好后,可以发现其中包含了棋盘中所有填充点位的坐标、活跃形状的坐标,我们只要根据坐标将二者分别渲染出来即可。这里以 React 为例,还是通过最简单的方式,根据活跃形状坐标和棋盘填充坐标,使用 DOM 将所有高亮色块填充:
class Playground extends Component<GameProps> {
game = new Game(new Ground({ size: this.props.groundSize }), shapes);
handleKeyDown = (e: KeyboardEvent) => {
const game = this.game;
if (e.code === 'Space') game.start();
if (!game.started) return;
if (e.code === 'ArrowLeft') game.moveLeft();
if (e.code === 'ArrowRight') game.moveRight();
if (e.code === 'ArrowDown' && !e.repeat) game.speedUp();
if (e.code === 'ArrowUp') game.rotate();
if (e.code === 'KeyD') game.moveToBottom();
};
handleKeyUp = (e: KeyboardEvent) => {
const game = this.game;
if (!game.started) return;
if (e.code === 'ArrowDown') game.speedDown();
};
handleGameEnd = () => {
window.alert('game end');
this.game.reset();
};
componentDidMount() {
window.addEventListener('keydown', this.handleKeyDown);
window.addEventListener('keyup', this.handleKeyUp);
this.game.event.on('end', this.handleGameEnd);
}
renderActiveShape() {
const activeShape = this.game.activeShape;
if (!activeShape) return;
const pointsInGround = activeShape.points.filter((p) => !this.game.ground.isPointOutOfGround(p));
const pointSize = this.props.pointSize;
return pointsInGround.map((p) => (
<div
key={`${p[0]}${p[1]}`}
style={{
position: 'absolute',
width: pointSize,
height: pointSize,
left: p[0] * pointSize,
top: p[1] * pointSize,
background: '#2fad44',
border: '1px solid rgba(255, 255, 255, .2)',
boxSizing: 'border-box',
}}
></div>
));
}
renderFilledPoint() {
const ground = this.game.ground.clone();
const points = ground.toPoints().filter((p) => !ground.isPointEmpty(p));
const pointSize = this.props.pointSize;
return points.map((p) => (
<div
key={`${p[0]}${p[1]}`}
style={{
position: 'absolute',
width: pointSize,
height: pointSize,
left: p[0] * pointSize,
top: p[1] * pointSize,
background: '#f3904f',
border: '1px solid rgba(255, 255, 255, .2)',
boxSizing: 'border-box',
}}
></div>
));
}
render() {
const width = this.props.groundSize[0] * this.props.pointSize + 2;
const height = this.props.groundSize[1] * this.props.pointSize + 2;
return (
<div
style={{
position: 'relative',
margin: '48px auto 0',
width,
height,
border: '1px solid rgba(23, 26, 29, 0.16)',
boxSizing: 'border-box',
}}
>
{this.renderActiveShape()}
{this.renderFilledPoint()}
</div>
);
}
}
实际效果如下图:
结语
本次项目实践以先模型后渲染为原则,先进行问题抽象,抓住关键对象和状态,设计相应模型。拥有纯模型层的项目要比渲染和业务逻辑混杂的项目要更好维护,具体好处不限于:随意更换渲染层、方便测试、可以将 API 直接提供给外部使用等。
处于复杂度考虑,本项目在实践时均使用了相对简单的实现方式,但如果再进一步思考,可以发现还有很多优化空间。例如:
-
棋盘格内每个点位均占据内存,但其实其存储结构可以被压缩
-
大部分时间里棋盘格都是静态无需渲染的,但目前每次活跃形状的移动也会引起棋盘刷新
项目虽小,但如果关心每个细节的话,是能引发很多关于程序设计的思考的。如果你有其它更新颖的想法,欢迎讨论。
项目完整代码可以查看:github.com/user65536/t…