JavaScript 如何实现俄罗斯方块游戏(多图)

1,498 阅读10分钟

某天下班回家的路上闲来无事,突然想到可以自己做一些经典小游戏的项目。在不查资料的情况下,自行构思代码结构、抽象模型。俄罗斯方块虽然是一个比较简单的游戏,但如果想把每个逻辑要点都分析清楚还是需要一些时间的。本文将带你跟随我的设计思路,从零开始,用面向对象的方式完成俄罗斯方块小游戏的模型设计,并使用 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;

棋盘格类

形状会在棋盘内移动或旋转。若形状在经过一次操作后超过边界,或与其它占据位置的格子重叠,则此次操作将无法完成。为此,我们可以定义一些工具方法,用于判断形状是否越界。同样简单起见,我们可以首先判断某个坐标是否越界,若形状中的每一个坐标都未越界,则此形状未越界。

注意代码中的可落盘,代表的是形状在游戏运行过程中处于一个合法的位置,并不代表真的要在这里落盘。而游戏中当一个形状刚刚诞生时,是处于上边界外的。为了云讯这种状态存在,这里我们用了两个方法来判断形状是否可落盘,即isPointOutOfSideisPointEmpty。通过判断形状是否超出左右边界、形状是否和棋盘中已填充的色块重叠,来判断位置是否合法。

由于方块填满一行后,当前行需要被清除,我们可以再定义一个方法 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…