code

/*
 * @Author: geek
 * @LastEditors: geek
 * @Description: 【俄罗斯方块核心计算文件】依赖 tetris.config
 * @Src: https://geek.qq.com/tetris/js/tetris.core.js (编译前的源文件)
 */
((global) => {
  // 游戏相关的配置
  const { config } = global;

  // 新方块的初始中心点在画布上的默认出现位置,默认从第1行第5列进场(方块的所有格子以此中心点为偏移量绘制)
  // 注:游戏使用的坐标系为 canvas 坐标系(坐标原点在左上角)详见:https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API/Tutorial/Drawing_shapes
  const defaultBrickCenterPos = [4, 0];

  // 随机数生成函数的配置
  const randomConfig = {
    a: 27073, // 乘子
    M: 32749, // 模数
    C: 17713, // 增量
    v: 12358, // 随机数种子
  };

  class Tetris {
    shapeIndex = 0; // 方块类型的索引
    stateIndex = 0; // 某个方块类型的在状态索引(一种类型的方块,由于可旋转,对应有多种状态)
    grids = []; // 当前所有格子的状态,已占用的值为绘制该格子的颜色值,未占用的值为空字符串
    brickCount = 0; // 已出现的方块个数
    curRandomNum = randomConfig.v; // 用于运算当前方块的随机数,初始值为随机数种子
    maxBrickCount = 10000; // 允许出现的方块总数,超过此值后结束游戏
    curBrickCenterPos = null; // 当前方块中心点在画布上的位置
    curBrickRawInfo = { pos: null, color: '' }; // 当前方块的原始信息(配置中的原始位置 + 方块颜色)
    curBrickInfo = {}; // 当前方块在画布上的信息(画布中的位置 + 方块颜色)注:方块每个格子在画布中的位置 = 每个格子的原始位置 + 方块中心点的位置
    nextBrickRawInfo = {}; // 下一个方块的原始信息
    score = 0; // 当前得分
    status = 'stopped'; // 当前游戏状态,stopped: 结束, running:运行中, paused:暂停, starting:初始中
    opRecord = []; // 游戏的操作记录,包含位移、旋转、新建方块等记录信息

    /**
     * @description: 核心计算实例的构造函数
     * @param {object} opts 配置选项
     * @return {*}
     */
    constructor(opts = {}) {
      this.opts = opts;
      this.init(opts);
    }

    /**
     * @description: 实例化初始函数
     * @param {object} opts 配置选项
     * @return {*}
     */
    init(opts = {}) {
      Object.keys(config).forEach((key) => {
        this[key] = Object.assign(config[key], opts[key]);
      });
    }

    /**
     * @description: 设置当前游戏的状态
     * @param {string} status 游戏的状态值
     * @return {*}
     */
    setStatus(status) {
      this.status = status;
      this.opts.onStatusChange && this.opts.onStatusChange({ status, score: this.score });
    }

    /**
     * @description: 获取每局的初始格子,全部填充空字符串
     * @param {object} gridConfig 画布网格配置
     * @return {array}
     */
    getInitGrids(gridConfig) {
      const { col, row } = gridConfig;
      const ret = [];

      for (let i = 0; i < row; i++) {
        ret.push(new Array(col).fill(''));
      }

      return ret;
    }

    /**
     * @description: 获取当前方块的信息,包含:原始位置、画布位置、颜色
     * @param {number} randomNum 随机数
     * @param {number} brickCount 已出现的方块数
     * @param {array} brickCenterPos 当前方块的中心点位置
     * @return {object}
     */
    getBrickInfo(randomNum, brickCount, brickCenterPos, mute) {
      const brickRawInfo = this.getRawBrick(randomNum, brickCount, mute);
      const { isValid, brickInfo } = this.getBrickPos(brickRawInfo, brickCenterPos, true);

      return {
        isValid,
        brickRawInfo,
        brickInfo,
      };
    }

    /**
     * @description: 获取方块的原始信息,包含:原始位置、颜色
     * @param {number} randomNum 随机数
     * @param {number} brickCount 已出现的方块数
     * @param {boolean} mute 是否需要将当前索引值更新到实例属性
     * @return {object}
     */
    getRawBrick(randomNum, brickCount, mute) {
      const { shapes, colors } = this;
      const { shapeIndex, stateIndex, colorIndex } = this.getShapeInfo(randomNum, brickCount);

      if (!mute) {
        this.shapeIndex = shapeIndex;
        this.stateIndex = stateIndex;
      }

      return {
        pos: shapes[shapeIndex][stateIndex],
        color: colors[colorIndex],
      };
    }

    /**
     * @description: 从配置中按照【固定概率】获取原始方块类型和形态的索引
     * @param {number} randomNum 随机数
     * @param {number} brickCount 已出现的方块数
     * @return {object}
     */
    getShapeInfo(randomNum, brickCount) {
      const { shapes, colors } = this;

      const weightIndex = randomNum % 29; // 对形状的概率有一定要求:限制每种砖块的出现概率可以让游戏变得更有挑战性
      const stateIndex = brickCount % shapes[0].length; // 形态概率要求不高,随机即可
      const colorIndex = brickCount % colors.length; // 颜色概率要求不高,随机即可
      let shapeIndex = 0;

      // const testShapeIndex = Math.floor(brickCount / 4);
      // const testStateIndex = brickCount % shapes[0].length;

      // I,L,J,T,O,S,Z 型方块的概率权重分别为:2,3,3,4,5,6,6(和为29)
      if (weightIndex >= 0 && weightIndex <= 1) {
        shapeIndex = 0;
      } else if (weightIndex > 1 && weightIndex <= 4) {
        shapeIndex = 1;
      } else if (weightIndex > 4 && weightIndex <= 7) {
        shapeIndex = 2;
      } else if (weightIndex > 7 && weightIndex <= 11) {
        shapeIndex = 3;
      } else if (weightIndex > 11 && weightIndex <= 16) {
        shapeIndex = 4;
      } else if (weightIndex > 16 && weightIndex <= 22) {
        shapeIndex = 5;
      } else if (weightIndex > 22) {
        shapeIndex = 6;
      }

      return { shapeIndex, stateIndex, colorIndex };
      // return { shapeIndex: testShapeIndex, stateIndex: testStateIndex, colorIndex };
    }

    /**
     * @description: 随机数算法,基于入参 number 计算出符合概率要求的新随机数(该算法保证了不同玩家、不同设备下的随机数列一致,也即方块出现的顺序一致)
     * @param {number} v 随机数种子
     * @return {number}
     */
    getRandomNum(v) {
      const { a, C, M } = randomConfig; // a:乘子,C:模数、C:增量

      return (v * a + C) % M;
    }

    /**
     * @description: 根据方块的原始位置和中心点位置,得到方块在画布上的位置,并判断是否合法
     * @param {object} brickRawInfo 方块原始信息
     * @param {object} brickCenterPos 中心点位置
     * @return {object}
     */
    getBrickPos(brickRawInfo, brickCenterPos, forceUpdate = false) {
      const { colors, brickCount } = this;
      const [x, y] = brickCenterPos;
      const calced = brickRawInfo.pos.map(([posX, posY]) => {
        return [posX + x, posY + y];
      });

      const brickInfo = {
        pos: calced,
        color: colors[brickCount % colors.length],
      };

      if (this.isBrickPosValid(brickInfo)) {
        return {
          isValid: true,
          brickInfo,
        };
      }

      return {
        isValid: false,
        brickInfo: forceUpdate ? brickInfo : this.curBrickInfo,
      };
    }

    /**
     * @description: 当一个方块落定,更新格子(是否有消除行)、分数,并返回是否堆叠触顶或者超出允许的最大方块数量
     * @param {*}
     * @return {object}
     */
    update() {
      const { pos, color } = this.curBrickInfo;

      pos.forEach(([x, y]) => {
        this.grids[y] && (this.grids[y][x] = color);
      });

      const fullRowIndexes = [];
      let occupiedGridCount = 0;
      let minOccupiedRowIndex = this.gridConfig.row - 1;

      this.grids.forEach((row, rowIndex) => {
        let occupiedGrirdCountPerRow = 0;

        // 每行已占用的格子计数
        row.forEach((grid) => {
          if (grid) {
            occupiedGrirdCountPerRow += 1;
          }
        });

        // 当前行有被占用的格子,被占用行计数加1
        if (occupiedGrirdCountPerRow > 0) {
          minOccupiedRowIndex = rowIndex < minOccupiedRowIndex ? rowIndex : minOccupiedRowIndex;
        }

        // 当前行所有格子都被占用,满行计数加1
        if (occupiedGrirdCountPerRow === row.length) {
          fullRowIndexes.push(rowIndex);
        }

        occupiedGridCount += occupiedGrirdCountPerRow;
      });

      const ret = {
        topTouched: minOccupiedRowIndex === 0,
        isRoundLimited: this.brickCount >= this.maxBrickCount,
      };

      // 触顶或者超过游戏的最大方块数量时,不计分数
      if (ret.topTouched || ret.isRoundLimited) {
        return ret;
      }

      let score = 0;
      // 分数计算规则(富贵险中求):界面上堆砌的格子数乘以当前消除行数的得分系数
      // 当前消除行的得分系数:消除的行数越多,系数随之增加
      // 如:当前I型方块消除的行数为 18,17,15 共 3 行,则得分为 occupiedGridCount * 6
      switch (fullRowIndexes.length) {
        case 1:
          score += occupiedGridCount * 1;
          break;
        case 2:
          score += occupiedGridCount * 3;
          break;
        case 3:
          score += occupiedGridCount * 6;
          break;
        case 4:
          score += occupiedGridCount * 10;
          break;
        case 0:
        default:
          score += 0;
      }
      fullRowIndexes.forEach((index) => {
        this.grids.splice(index, 1);
        this.grids.unshift(new Array(this.gridConfig.col).fill(''));
      });

      this.score += score;
      this.opts.onScoreChange &&
        this.opts.onScoreChange({ status: this.status, score: this.score });

      return ret;
    }

    /**
     * @description: 方块位置是否合法(边界检测)
     * @param {array} param.pos 当前方块在画布上的位置
     * @return {*}
     */
    isBrickPosValid({ pos }) {
      const { row, col } = this.gridConfig;
      const xRange = [0, col - 1];
      const yRange = [0, row - 1];
      let validCount = 0;

      // 逐点检测是否合法
      pos.forEach(([x, y]) => {
        const isHorizontalValid = x >= xRange[0] && x <= xRange[1]; // 水平方向是否合法
        const isVerticalValid = y <= yRange[1]; // 垂直方向是否合法
        const isCurGridValid = y < 0 || (this.grids[y] && !this.grids[y][x]); // 当前格子是否已被占用

        validCount += isHorizontalValid && isVerticalValid && isCurGridValid ? 1 : 0;
      });

      // 每个格子都合法才认为该方块合法
      return validCount === 4;
    }

    /**
     * @description: 获取当前方块在画布上各方向的格子间隙(也即各方向还能移动多少格)
     * @param {object} gridConfig 画布网格配置
     * @param {object} brickInfo 方块信息
     * @param {array} grids 画布网格信息
     * @return {object}
     */
    getBrickGaps(gridConfig, brickInfo, grids) {
      const ret = {
        top: gridConfig.row,
        right: gridConfig.col,
        bottom: gridConfig.row,
        left: gridConfig.col,
      };

      brickInfo.pos.forEach(([x, y]) => {
        const curGaps = {
          top: 0,
          right: 0,
          bottom: 0,
          left: 0,
        };

        // 左侧间隙计算
        for (let i = x - 1; i >= 0; i--) {
          if (!(grids[y] && !grids[y][i]) && grids[y]) break;
          curGaps.left += 1;
        }

        // 右侧间隙计算
        for (let i = x + 1; i < gridConfig.col; i++) {
          if (!(grids[y] && !grids[y][i]) && grids[y]) break;
          curGaps.right += 1;
        }

        // 顶部间隙计算
        for (let i = y - 1; i >= 0; i--) {
          if (!(grids[i] && !grids[i][x]) && grids[i]) break;
          curGaps.top += 1;
        }

        // 底部间隙计算
        for (let i = y + 1; i < gridConfig.row; i++) {
          if (!(grids[i] && !grids[i][x]) && grids[i]) break;
          curGaps.bottom += 1;
        }

        ['top', 'right', 'bottom', 'left'].forEach((dir) => {
          if (curGaps[dir] < ret[dir]) {
            ret[dir] = curGaps[dir];
          }
        });
      });

      return ret;
    }
    /**
     * @description: 移动方块,并返回移动后各方向的空格间隙
     * @param {string} dir 移动方向
     * @param {number} stepCount 移动步数
     * @return {*}
     */
    move(dir, stepCount = 1) {
      const centerPos = this.curBrickCenterPos.slice();
      switch (dir) {
        case 'left':
          centerPos[0] -= stepCount;
          break;
        case 'right':
          centerPos[0] += stepCount;
          break;
        case 'down':
          centerPos[1] += stepCount;
          break;
      }

      const { isValid, brickInfo } = this.getBrickPos(this.curBrickRawInfo, centerPos);
      const gaps = this.getBrickGaps(this.gridConfig, this.curBrickInfo, this.grids);

      if (isValid) {
        this.curBrickInfo = brickInfo;
        this.curBrickCenterPos = centerPos;
        this.trackOp(dir, stepCount);
      }

      return gaps;
    }

    /**
     * @description: 旋转方块(实际为按照 stateIndex 渲染对应的方块形态)
     * @param {*}
     * @return {*}
     */
    rotate() {
      let { stateIndex } = this;

      if (this.stateIndex >= 3) {
        stateIndex = 0;
      } else {
        stateIndex += 1;
      }

      const curBrickRawInfo = {
        pos: this.shapes[this.shapeIndex][stateIndex],
        color: this.curBrickRawInfo.color,
      };

      const { isValid, brickInfo } = this.getBrickPos(curBrickRawInfo, this.curBrickCenterPos);

      if (isValid) {
        this.stateIndex = stateIndex;
        this.curBrickRawInfo = curBrickRawInfo;
        this.curBrickInfo = brickInfo;
        this.trackOp('rotate');
      }
    }

    /**
     * @description: 下坠方块到底部
     * @param {*}
     * @return {*}
     */
    drop() {
      const { bottom } = this.getBrickGaps(this.gridConfig, this.curBrickInfo, this.grids);

      this.move('down', bottom);
    }

    /**
     * @description: 记录方块相关的历史操作
     * @param {string} type 动作类型
     * @param {number} stepCount 动作次数
     * @return {*}
     */
    trackOp(type, stepCount = 1) {
      if (this.status !== 'running') return;
      type = this.actionMap[type];

      const prevOp = this.opRecord.pop();
      const { type: prevType, count: prevCount } = this.getOpInfo(prevOp);
      if (type === prevType && type !== 'N') {
        this.opRecord.push(`${prevType}${prevCount + stepCount}`);
      } else {
        prevOp && this.opRecord.push(prevOp);
        if (type === 'D' && stepCount > 1) {
          this.opRecord.push(`D${stepCount}`);
        } else {
          this.opRecord.push(`${type}${type !== 'N' ? '1' : ''}`);
        }
      }
    }

    /**
     * @description: 将动作和次数指令字符串转换为 type 和 count
     * @param {string} singleOpCmd 单个操作记录
     * @return {object}
     */
    getOpInfo(singleOpCmd = '') {
      singleOpCmd = singleOpCmd.trim();
      const reg = /^([LRDCN])(\d*)$/g;
      const [, type, count] = reg.exec(singleOpCmd) || ['', '', ''];

      return {
        type,
        count: count ? +count : undefined,
      };
    }

    /**
     * @description: 重置游戏相关状态
     * @param {function} onAnimate 动画函数
     * @return {*}
     */
    async reset(onAnimate) {
      this.score = 0;
      this.setStatus('starting');
      this.opRecord = [];
      this.brickCount = 0;
      this.curRandomNum = randomConfig.v;
      await this.clearGrids(onAnimate);
    }

    /**
     * @description: 初始化画布网格
     * @param {*}
     * @return {*}
     */
    initGrids() {
      this.grids = this.getInitGrids(this.gridConfig);
    }

    /**
     * @description: 初始化方块
     * @param {*}
     * @return {*}
     */
    initBrick() {
      this.curRandomNum = this.getRandomNum(this.curRandomNum);
      // 获取当前方块信息
      const curBrickCenterPos = defaultBrickCenterPos.slice();
      const { isValid, brickRawInfo, brickInfo } = this.getBrickInfo(
        this.curRandomNum,
        this.brickCount,
        curBrickCenterPos
      );

      // 获取下一个方块信息
      const { brickRawInfo: nextBrickRawInfo } = this.getBrickInfo(
        this.getRandomNum(this.curRandomNum),
        this.brickCount + 1,
        curBrickCenterPos,
        true
      );

      this.curBrickCenterPos = curBrickCenterPos;
      this.curBrickRawInfo = brickRawInfo;
      this.curBrickInfo = brickInfo;
      this.brickCount += 1;
      this.trackOp('new');

      if (isValid) {
        this.nextBrickRawInfo = nextBrickRawInfo;
      }

      return { isValid, brickCount: this.brickCount };
    }

    /**
     * @description: 清屏操作(动画外置,作为参数传入)
     * @param {function} onAnimate 动画函数
     * @return {*}
     */
    async clearGrids(onAnimate) {
      for (let i = this.gridConfig.row; i >= 0; i--) {
        this.grids[i] = new Array(this.gridConfig.col).fill('#00b050');
        onAnimate && (await onAnimate(this.grids));
      }

      this.clearBrick();

      for (let i = 0; i < this.gridConfig.row; i++) {
        this.grids[i] = new Array(this.gridConfig.col).fill('');
        onAnimate && (await onAnimate(this.grids));
      }
    }

    /**
     * @description: 清除当前方块
     * @param {*}
     * @return {*}
     */
    clearBrick() {
      this.curBrickCenterPos = [];
      this.curBrickRawInfo = {};
      this.curBrickInfo = {};
    }

    getSnapshot() {
      const { grids, curBrickInfo } = this;
      let gridsStr = '     0  1  2  3  4  5  6  7  8  9 \n     ----------------------------\n';
      let brickStr = '     0  1  2  3  4  5  6  7  8  9 \n     ----------------------------\n';

      grids.forEach((row, rowIndex) => {
        let head = `${rowIndex}`;

        head = head.padStart(2, 0);
        gridsStr += `${head} |`;
        brickStr += `${head} |`;

        row.forEach((grid, colIndex) => {
          gridsStr += grid ? ' # ' : ' . ';

          const isBrickPos =
            curBrickInfo.pos.findIndex(([x, y]) => rowIndex === y && colIndex === x) > -1;
          brickStr += isBrickPos ? ' # ' : ' . ';
        });

        gridsStr += '\n';
        brickStr += '\n';
      });

      return {
        gridsStr,
        brickStr,
      };
    }
  }

  global.Tetris = Tetris;
})(window);
/*
 * @Author: geek
 * @LastEditors: geek
 * @Description: 【俄罗斯方块配置文件】
 * @Src: https://geek.qq.com/tetris/js/tetris.config.js (编译前的源文件)
 */
((global) => {
  const config = {
    // 格子宽高及单位配置
    gridConfig: {
      width: 200,
      height: 400,
      row: 20,
      col: 10,
    },

    // 方块的颜色,可设置为不同颜色,当前设置为统一色
    colors: ['#00b050', '#00b050', '#00b050', '#00b050'],

    // 7 种类型方块(I,L,J,T,O,S,Z)的形态及每种类型对应的 4 个形态
    // 注:游戏使用的坐标系为 canvas 坐标系(坐标原点在左上角)详见:https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API/Tutorial/Drawing_shapes
    shapes: [
      [
        // I 型
        [
          [0, 0],
          [0, -1],
          [0, -2],
          [0, 1],
        ],
        [
          [0, 0],
          [1, 0],
          [2, 0],
          [-1, 0],
        ],
        [
          [0, 0],
          [0, -1],
          [0, -2],
          [0, 1],
        ],
        [
          [0, 0],
          [1, 0],
          [2, 0],
          [-1, 0],
        ],
      ],
      [
        // L 型
        [
          [0, 0],
          [0, -1],
          [0, -2],
          [1, 0],
        ],
        [
          [0, 0],
          [1, 0],
          [2, 0],
          [0, 1],
        ],
        [
          [0, 0],
          [-1, 0],
          [0, 1],
          [0, 2],
        ],
        [
          [0, 0],
          [0, -1],
          [-1, 0],
          [-2, 0],
        ],
      ],
      [
        // J 型
        [
          [0, 0],
          [0, -1],
          [0, -2],
          [-1, 0],
        ],
        [
          [0, 0],
          [0, -1],
          [1, 0],
          [2, 0],
        ],
        [
          [0, 0],
          [1, 0],
          [0, 1],
          [0, 2],
        ],
        [
          [0, 0],
          [-1, 0],
          [-2, 0],
          [0, 1],
        ],
      ],
      [
        // T 型
        [
          [0, 0],
          [1, 0],
          [0, 1],
          [-1, 0],
        ],
        [
          [0, 0],
          [0, -1],
          [0, 1],
          [-1, 0],
        ],
        [
          [0, 0],
          [0, -1],
          [1, 0],
          [-1, 0],
        ],
        [
          [0, 0],
          [0, -1],
          [1, 0],
          [0, 1],
        ],
      ],
      [
        // O 型
        [
          [0, 0],
          [0, -1],
          [1, -1],
          [1, 0],
        ],
        [
          [0, 0],
          [0, -1],
          [1, -1],
          [1, 0],
        ],
        [
          [0, 0],
          [0, -1],
          [1, -1],
          [1, 0],
        ],
        [
          [0, 0],
          [0, -1],
          [1, -1],
          [1, 0],
        ],
      ],
      [
        // S 型
        [
          [0, 0],
          [0, -1],
          [1, -1],
          [-1, 0],
        ],
        [
          [0, 0],
          [-1, 0],
          [-1, -1],
          [0, 1],
        ],
        [
          [0, 0],
          [0, -1],
          [1, -1],
          [-1, 0],
        ],
        [
          [0, 0],
          [-1, 0],
          [-1, -1],
          [0, 1],
        ],
      ],
      [
        // Z 型
        [
          [0, 0],
          [0, -1],
          [1, 0],
          [-1, -1],
        ],
        [
          [0, 0],
          [0, -1],
          [-1, 1],
          [-1, 0],
        ],
        [
          [0, 0],
          [0, -1],
          [1, 0],
          [-1, -1],
        ],
        [
          [0, 0],
          [0, -1],
          [-1, 1],
          [-1, 0],
        ],
      ],
    ],

    // 操作动作到提交类型的映射
    actionMap: {
      down: 'D', // 下降
      left: 'L', // 左移
      right: 'R', // 右移
      rotate: 'C', // 旋转
      new: 'N', // 新方块
    },

    // 操作动作到提交类型的反向映射
    actionMapReversed: {
      D: 'down',
      L: 'left',
      R: 'right',
      C: 'rotate',
      N: 'new',
    },
  };

  global.config = config;
})(window);
/*
 * @Author: geek
 * @LastEditors: geek
 * @Description: 【俄罗斯方块游戏主文件】依赖 tetris.core
 * @Src: https://geek.qq.com/tetris/js/tetris.game.js (编译前的源文件)
 *
 * 游戏介绍:
 * 1、将 10000 块按固定顺序出现的方块堆叠,有消除行即得分,看谁得分高
 * 2、游戏分正式模式和回放模式,正式模式用于 PK 打榜,回放模式(playRecord)目前仅提供用于 debug 操作记录和对应的分数(暂未开放使用)
 * 3、方块下落速度会随着出现的方块数量加快,每 100 个方块后,速度递减 100ms,原始速度 1000ms,最快 100ms
 * 4、画布垂直方向满屏后,结束游戏
 * 5、方块出现的总数最大为 10000 个,超过后结束游戏
 * 6、每个方块的类型(已有:I,L,J,T,O,S,Z 型方块)、形态(各类型每旋转90度后的形态)会从配置中按照统一顺序、限定概率地读取出来,保证所有人遇到的方块顺序和方块概率都一致
 * 7、积分规则:当前方块的消除得分 = 画布中已有的格子数 * 当前方块落定后所消除行数的系数,每消除 1、2、3、4 行的得分系数依次为:1、3、6、10(例:画布当前一共有 n 个格子,当前消除行数为2,则得分为:n * 3)
 * 8、游戏结束触发规则:1)、方块落定后触顶;2)、新建方块无法放置(画布上用于放置方块的格子中有已被占用的)
 *
 * 注:游戏中优先判定是否结束游戏再计分。如:极限情况下,当前方块落定后产生了可消除行,但触顶或者超过最大方块数了,此轮不计分,直接结束游戏
 * 注:游戏使用的坐标系为 canvas 坐标系(坐标原点在左上角)详见:https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API/Tutorial/Drawing_shapes
 *
 */
((global) => {
  const defaultPlayFreq = 1000;
  const defaultReplayFreq = 10;

  class Game {
    dpr = window.devicePixelRatio || 1; // 设备 dpr,保证在高分屏设备下高清绘制 canvas
    playFreq = defaultPlayFreq; // 游戏中每帧绘制的频率,会随着方块数增加而加快
    speed = 1; // 游戏速度展示值,会随着方块数增加而加快(取值:1到10)
    speedUpCount = 100; // 触发游戏加速的间隔方块数(每100个方块后加速一次,初始为1000ms,每次递减100ms,最快 100ms)
    replayFreq = defaultReplayFreq; // 游戏中方块下降的速度,随游玩过的增加
    record = []; // 操作记录,用于回放和成绩提交
    mode = 'play'; // 游戏播放模式,play:正常游玩模式,replay:基于操作记录的回放模式
    timer = null; // play 模式下的主逻辑 timer
    replayTimer = null; // replay 模式下的主逻辑 timer
    keydownTimer = null; // 游戏操作按键的 timer,增强移动端按压操作按钮后达到连续移动的效果

    /**
     * @description: Game 实例的构造函数
     * @param {htmlElement} canvas 画布元素
     * @param {object} opts 配置选项
     * @return {*}
     */
    constructor(canvas, opts = {}) {
      this.opts = opts;
      this.tetris = new global.Tetris(opts); // 从 Tetris 实例化 tetris 核心计算对象,核心计算全包含在此类中
      this.canvas = canvas;
      this.ctx = this.canvas.getContext('2d');
      this.bindEvents(); // 绑定键盘操作
    }

    /**
     * @description: 开始游戏
     * @param {*}
     * @return {*}
     */
    async start() {
      const { status } = this.tetris;

      if (status === 'starting') return; // 游戏已在重启中,等待重启完成,不作处理
      this.mode = 'play'; // 设置为游戏模式(另有回放模式 replay)
      await this.reset(); // 重置相关状态和数据,且播放清屏动画
      // this.play();
      this.tetris.setStatus('running'); // 设定 tetris 为 running 状态
      this.tetris.initGrids(); // 初始格子
      this.tetris.initBrick(); // 初始方块
      this.startAutoRun(true); // 启动对应模式下的 timer,自动运行游戏
    }

    /**
     * @description: 重置相关状态和数据,且播放清屏动画
     * @param {*}
     * @return {*}
     */
    async reset() {
      this.record = [];
      this.speed = 1;
      this.playFreq = defaultPlayFreq;
      this.pause(); // 清屏前先暂停当前的状态,以免清屏动画和当前主线动画冲突
      this.opts.onSpeedChange && this.opts.onSpeedChange({ speed: this.speed });

      // 重置 tetris 实例内的状态,并在回调中清屏动画
      await this.tetris.reset(async () => {
        await this.sleep(20);
        this.render();
      });
    }

    /**
     * @description: 开启自动运行游戏
     * @param {boolean} isInit 是否是初始行为
     * @return {*}
     */
    startAutoRun(isInit) {
      const { mode } = this;
      const timerId = `${mode}Timer`; // 对应的轮询 timer
      const freqId = `${mode}Freq`; // 对应的运行频率
      const stepFun = `${mode}Step`; // 单步逻辑函数,playStep 和 replayStep

      if (this.tetris.status === 'running') {
        isInit && this.render(); // 初次开启时先渲染一帧

        // 按照对应模式的频率循环执行对应单步逻辑
        this[timerId] = setTimeout(() => {
          this[stepFun]();
          this.startAutoRun();
        }, this[freqId]);
      }
    }

    /**
     * @description: 每个拟定时间间隔自动执行或者按键操作后的单步逻辑函数(位移n格,判断是否得分、触及边界等)
     * @param {string} dir 位移方向
     * @param {number} stepCount 位移步数
     * @param {boolean} needUpdate 是否需要更新
     * @param {boolean} forceUpdate 是否强制更新(悬停时触发)
     * @return {*}
     */
    async playStep(dir = 'down', stepCount = 1, needUpdate = true, forceUpdate = false) {
      let bottom = undefined;
      let isStepValid = true;

      if (!forceUpdate) {
        // 先执行位移
        bottom = this.tetris.move(dir, stepCount).bottom;
      }

      // 方块位移后触底 或 强制更新情况下(如:悬空),判定为方块落定
      if ((needUpdate && bottom === 0) || forceUpdate) {
        // 方块落定后,更新状态
        const { topTouched, isRoundLimited } = this.tetris.update();

        // 触顶或者超过游戏的最大方块数量后,结束游戏
        if (topTouched || isRoundLimited) {
          const { maxBrickCount, brickCount } = this.tetris;

          this.gameOver(
            `方块是否触顶:${topTouched}(当前为第 ${brickCount} 个方块),方块数是否超过限制:${isRoundLimited}(最大方块数:${maxBrickCount})`
          );
        } else {
          // 未触顶且未超过游戏的最大方块数量,新建方块,并判断新建的方块是否合法
          const { isValid, brickCount } = this.tetris.initBrick();

          isStepValid = isValid;

          // 新方块不合法(无法再摆放)时,结束游戏
          if (isValid) {
            this.updateSpeed();
          } else {
            this.gameOver(`已无法摆放第 ${brickCount} 个初始方块`);
          }
        }
      }

      // 单步逻辑执行后合法,绘制最新的状态到 canvas 上
      this.render(isStepValid);
    }

    /**
     * @description: 重放游戏记录(调试 record 记录可以调用此方法)
     * @param {array} record 操作历史记录,如: ['N', 'D19', 'N', 'D15', 'N', 'D14', 'N', 'D10', 'N', 'D9', 'N', 'D6', 'N', 'D4', 'N', 'D1'] 可以在界面上完整地回放一段操作记录
     * @return {*}
     */
    async playRecord(record = []) {
      const { status } = this.tetris;
      const { isValid, msg } = this.validateRecord(record);

      if (!isValid) {
        console.error(
          `${msg}\n参考格式:指令类型标识 + 多位数字(如:L1, D12, N, C2, R2,其中指令 N 不能带数字)。合法的指令标识符有:L(左移), R(右移), D(下降), C(旋转), N(新方块)`
        );
        return;
      }
      if (status === 'starting') return;
      this.mode = 'replay';
      await this.reset();
      this.record = record;
      // this.replay();
      this.tetris.setStatus('running');
      this.tetris.initGrids();
      this.startAutoRun(true);
    }

    /**
     * @description: 每个操作记录对应的重放单步逻辑(位移n格、旋转,判断是否得分、触及边界等)
     * @param {*}
     * @return {*}
     */
    async replayStep() {
      const { record } = this;

      if (record.length) {
        const curOp = record.shift(); // 当前动作类型对应的操作记录
        const [nextOp] = record; // 下一个动作类型对应的操作记录
        const opRecord = this.tetris.getOpInfo(curOp); // 包含动作类型 type 和该动作的重复次数 count,如:D2 --> { type: 'down', count: 2}
        const { type: nextType } = this.tetris.getOpInfo(nextOp);
        const curAction = this.tetris.actionMapReversed[opRecord.type];
        const nextAction = this.tetris.actionMapReversed[nextType];

        if (curAction === 'new') {
          // 构建新方块时
          const { isValid, brickCount } = this.tetris.initBrick();
          this.updateSpeed();

          // 新方块无法再摆放,结束游戏
          if (!isValid) {
            this.gameOver(`已无法摆放第 ${brickCount} 个初始方块`);
            return;
          }
        } else if (curAction && opRecord.count) {
          // 位移动作发生时
          if (['left', 'right', 'down'].indexOf(curAction) > -1) {
            this.tetris.move(curAction, 1);
          } else if (curAction === 'rotate') {
            // 旋转动作发生时
            this.tetris.rotate();
          }
          opRecord.count -= 1;
        }

        // 当前类型操作的执行次数还未执行完时,将剩余操作重新放入记录队列,待下一轮执行
        if (opRecord.count > 0) {
          record.unshift(`${opRecord.type}${opRecord.count}`);
        }

        // 当前类型操作类型执行完毕且下一类型为新建方块时,更新状态(某个方块落定)
        if (
          (opRecord.count === 0 && (nextAction === 'new' || !nextAction)) ||
          (curAction === 'new' && !record.length)
        ) {
          const { topTouched, isRoundLimited } = this.tetris.update();

          // 触顶或者超过游戏的最大方块数量后,结束游戏
          if (topTouched || isRoundLimited) {
            const { maxBrickCount, brickCount } = this.tetris;

            this.gameOver(
              `方块是否触顶:${topTouched}(当前为第 ${brickCount} 个方块),方块数是否超过限制:${isRoundLimited}(最大方块数:${maxBrickCount})`
            );
          }
        }

        // 执行从动作后将最新状态渲染到 canvas
        this.render();
      } else {
        // 操作记录消耗完毕后,回放完毕
        this.gameOver('操作记录运算完毕');
      }
    }

    /**
     * @description: 验证操作记录的每项是否合法
     * @param {array} record 操作历史记录数组
     * @return {boolean} 该数组是否合法
     */
    validateRecord(record) {
      const ret = { isValid: true, msg: '' };
      const opCountArr = [];
      let countBetweenNN = 0;

      for (let i = 0; i < record.length; i++) {
        const { type, count } = this.tetris.getOpInfo(record[i]);

        // 第一个动作不为 N 时
        if (i === 0 && type !== 'N') {
          ret.isValid = false;
          ret.msg = '操作序列只能以 N 指令开头';
          return ret;
        }

        if (type === 'N') {
          if (i !== 0 && i !== record.length - 1) {
            opCountArr.push(countBetweenNN);
            countBetweenNN = 0;
          }
          // N 指令带数字时
          if (count !== undefined) {
            ret.isValid = false;
            ret.msg = `N 指令不能带数字(第 ${i + 1} 个指令为 ${type}${count},请修改)`;
            return ret;
          }
        } else {
          countBetweenNN += +count;
          // 指令无法识别 或 非 N 指令不带数字时
          if (!type || (type && !count)) {
            ret.isValid = false;
            ret.msg = `存在无法识别的操作指令 或 操作指令没有带数字(第 ${i + 1} 个指令为 "${
              record[i]
            }",请修改)`;
            return ret;
          }
        }
      }
      opCountArr.push(countBetweenNN);

      const opCountIndex = opCountArr.findIndex((val) => val === 0 || val > 100);
      if (opCountIndex > -1) {
        ret.isValid = false;
        ret.msg = `两个方块之间的操作次数必须在区间 (0,100] 内(第 ${
          opCountIndex + 1
        } 个方块的操作次数为:${opCountArr[opCountIndex]})`;
      }

      return ret;
    }

    /**
     * @description: 更新游戏速度
     * @param {*}
     * @return {*}
     */
    updateSpeed() {
      const { speedUpCount, playFreq } = this;

      if (this.tetris.brickCount % speedUpCount === 0 && playFreq > 100) {
        this.speed += 1;
        this.playFreq -= 100;
        this.opts.onSpeedChange && this.opts.onSpeedChange({ speed: this.speed });
      }
    }

    /**  结束游戏
     * @description:
     * @param {*}
     * @return {*}
     */
    gameOver(reason = '') {
      const { opRecord, score, brickCount } = this.tetris;
      const { gridsStr, brickStr } = this.tetris.getSnapshot();

      this.stop();
      this.opts.onEnd && this.opts.onEnd({ score, brickCount, opRecord });

      const msg = `【游戏结束信息】
结束原因:${reason}
当前运行方块数:${brickCount}
当前得分:${score}
最后时刻的画布位置信息:(当最后一个砖块的位置合法时,将包含最后一个砖块在内)\n
${gridsStr}
最后时刻的砖块位置信息:
${brickStr}`;

      console.log(msg);
    }

    /**
     * @description: canvas 每帧的渲染逻辑,包含格子绘制、当前方块绘制、下一个方块的绘制
     * @param {*}
     * @return {*}
     */
    render(isBrickValid = true) {
      const vh = this.canvas.clientHeight / 100;
      const { gridConfig, curBrickInfo, grids, nextBrickRawInfo = {} } = this.tetris; // 从 tetris 计算核心实例总获取:网格配置、当前方块信息(位置和颜色)、当前所有网格的使用情况

      // 清空画布
      this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

      // 绘制当前方块
      const { pos = [], color } = curBrickInfo; // 获取当前方块的位置和颜色信息
      const gridWidth = (this.canvas.width * (3 / 4)) / gridConfig.col; // canvas 配置宽度的 3/4 用来绘制格子区域,1/4 用来绘制下一个方块
      const gridHeight = this.canvas.height / gridConfig.row;
      const gridGap = (vh / 8) * this.dpr; // 每个单元格的留白间隙
      if (isBrickValid) {
        this.renderBrick(pos, gridWidth, gridHeight, gridGap, color, []);
      }

      // 绘制下一个方块
      const { pos: nextRawPos = [], color: nextColor } = nextBrickRawInfo;
      const nextGridUnit = vh * 2 * this.dpr;
      const nextOffsetUnit = vh * 4 * this.dpr;
      const nextMarginLeft = this.canvas.width * (3 / 4) + nextOffsetUnit * 1.8;
      const nextMarginTop = nextOffsetUnit * 19;
      this.renderBrick(nextRawPos, nextGridUnit, nextGridUnit, vh / 12, nextColor, [
        nextMarginLeft,
        nextMarginTop,
      ]);

      // 绘制已占用的格子
      this.renderGrids(grids, gridWidth, gridHeight, gridGap);
    }

    /**
     * @description: 渲染当前方块
     * @param {array} pos 方块位置
     * @param {number} gridWidth 方块中每个格子的宽度
     * @param {number} gridHeight 方块中每个格子的高度
     * @param {number} gridGap 方块中格子之间的间隙
     * @param {string} color 方块颜色
     * @param {number} marginLeft 方块的左边距
     * @param {number} marginTop 方块的上边距
     * @return {*}
     */
    renderBrick(pos, gridWidth, gridHeight, gridGap, color, [marginLeft = 0, marginTop = 0]) {
      pos.length &&
        pos.forEach(([x, y]) => {
          const startX = x * gridWidth + marginLeft;
          const startY = y * gridHeight + marginTop;

          this.ctx.fillStyle = color;
          this.ctx.fillRect(
            startX + gridGap,
            startY + gridGap,
            gridWidth - gridGap * 2,
            gridHeight - gridGap * 2
          );
        });
    }

    /**
     * @description: 绘制已占用的格子
     * @param {array} grids 画布网格信息
     * @param {number} gridWidth 格子的高度
     * @param {number} gridHeight 格子的高度
     * @param {number} gridGap 格子之间的间隙
     * @return {*}
     */
    renderGrids(grids, gridWidth, gridHeight, gridGap) {
      grids.forEach((row, rowIndex) => {
        row.forEach((col, colIndex) => {
          if (col) {
            const startX = colIndex * gridWidth;
            const startY = rowIndex * gridHeight;

            this.ctx.fillStyle = col;
            this.ctx.fillRect(
              startX + gridGap,
              startY + gridGap,
              gridWidth - gridGap * 2,
              gridHeight - gridGap * 2
            );
          }
        });
      });
    }

    /**
     * @description: 绑定键盘事件
     * @param {*}
     * @return {*}
     */
    bindEvents() {
      window.addEventListener('keydown', this.onKeyDown.bind(this));
      window.addEventListener('keyup', this.onKeyUp.bind(this));
    }

    /**
     * @description: keydown 事件回调
     * @param {object} e 浏览器事件对象
     * @return {*}
     */
    onKeyDown(e) {
      this.keyDownHandler(e.keyCode);
      if (this.keydownTimer || [37, 39, 40].indexOf(e.keyCode) === -1) return;
      this.keydownTimer = setInterval(() => {
        this.keyDownHandler(e.keyCode);
      }, 150);
    }

    /**
     * @description: keyup 事件回调
     * @param {*}
     * @return {*}
     */
    onKeyUp() {
      clearInterval(this.keydownTimer);
      this.keydownTimer = null;
    }

    /**
     * @description: 键盘事件回调处理
     * @param {number} keyCode 事件类型对应的 code
     * @return {*}
     */
    keyDownHandler(keyCode) {
      const { status } = this.tetris;

      switch (keyCode) {
        // esc 键:重新开始
        case 27:
          if (status !== 'stopped') {
            this.start();
          }
          break;
        // 回车键:暂停/继续
        case 13:
          this.toggleAutoRun();
          break;
      }
      if (this.mode === 'play' && status === 'running') {
        switch (keyCode) {
          // 方向左键:左移动
          case 37:
            this.playStep('left', 1, false);
            break;

          // 方向右键:右移动
          case 39:
            this.playStep('right', 1, false);
            break;

          // 方向下键:下移动
          case 40:
            this.playStep('down', 1, false);
            break;

          // 方向上键:旋转
          case 38:
            this.tetris.rotate();
            this.render();
            break;

          // 空格键:下坠方块
          case 32:
            this.tetris.drop();
            this.playStep('down', 1);
            this.opts.onDrop();
            break;

          // ctrl 键:悬停方块
          case 17:
            this.playStep('', 0, false, true);
            break;
        }
      }
    }

    /**
     * @description: sleep 阻塞函数
     * @param {number} time 需要阻塞的时间,单位:ms
     * @return {*}
     */
    async sleep(time) {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve();
        }, time);
      });
    }

    /**
     * @description: 暂停游戏
     * @param {*} isEnd 是否结束游戏
     * @return {*}
     */
    pause(isEnd) {
      const timerId = `${this.mode}Timer`;

      if (isEnd) {
        this.tetris.setStatus('stopped');
      } else {
        if (this.tetris.status === 'stopped') return; // 已经结束,就不再置为暂停了
        this.tetris.setStatus('paused');
      }

      clearTimeout(this[timerId]);
    }

    /**
     * @description: 停止游戏
     * @param {*}
     * @return {*}
     */
    stop() {
      this.pause(true);
    }

    /**
     * @description: 继续游戏
     * @param {*}
     * @return {*}
     */
    resume() {
      this.tetris.setStatus('running');
      this.startAutoRun();
    }

    /**
     * @description: 切换暂停或开始
     * @param {*}
     * @return {*}
     */
    toggleAutoRun() {
      const { status } = this.tetris;

      if (status === 'running') {
        this.pause();
      } else if (status === 'paused') {
        this.resume();
      }
    }
  }

  global.Game = Game;
})(window);

类似文章

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注