原生JS(TS)实现消灭星星

727 阅读8分钟

资源准备

游戏效果

动图效果如下:

html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <link rel="stylesheet" href="./css/index.css">
</head>
<body>
<div class="pop_star">
    <div class="target_score"></div>
    <div class="current_score"></div>
    <div class="selecting_score"></div>
</div>
</body>
<script src="js/index.js"></script>
</html>

css

* {
    margin: 0;
    padding: 0;
}

html, body {
    width: 100%;
    height: 100%;
    font-size: 10px;
}

.pop_star {
    width: 400px;
    height: 100%;
    background-image: url("../img/background.png");
    background-repeat: no-repeat;
    margin: 0 auto;
    background-size: cover;
    font-size: 0;
    position: relative;
}

.target_score {
    width: 100%;
    height: 4rem;
    line-height: 4rem;
    text-align: center;
    font-size: 1.6rem;
    color: #fff;
}

.current_score {
    width: 100%;
    height: 4rem;
    line-height: 4rem;
    text-align: center;
    font-size: 1.6rem;
    color: #fff;
}

.selecting_score {
    width: 100%;
    height: 4rem;
    line-height: 4rem;
    text-align: center;
    font-size: 1.6rem;
    color: #fff;
    opacity: 0;
}

js

  由于是游戏开发所以语言使用Typescript,这样可以减少因为变量类型不一致而引起的bug。采用面向对象的方式进行游戏的抽象和复用。

确定对象

通过游戏面板我们很容易抽象成两个对象 star 和 game 对象。

星星对象

class Star {
  // row 和 col分别表示星星在二维数组中的位置
  public row: number;
  public col: number;
  // 宽度
  public readonly width: number = 40;
  // 高度
  public readonly height: number = 40;
  // 类型,用于生成星星图片
  public type: number;

  constructor(type: number, row: number, col: number) {
    this.row = row;
    this.col = col;
    this.type = type
  }

  /**
   * 创建div,给div添加一些样式
   */
  public createBlock(): HTMLDivElement {
    let {width, height} = this, div: any = <any>document.createElement('div');
    // 星星的类型
    div.type = this.type;
    // 星星的行和列
    div.row = this.row;
    div.col = this.col;
    // 添加类名
    div.className = 'star';
    // 添加样式
    div.style.width = this.width + "px";
    div.style.height = this.height + "px";
    div.style.display = "inline-block";
    div.style.position = "absolute";
    div.style.boxSizing = "border-box";
    div.style.borderRadius = "1.2rem";
    return div
  }
}

游戏面板

以下代码均是在面板类里面,所以游戏面板代码较多,我们依依进行书写。

初始化基础信息

class Game {
    // 星星宽度
      private starWidth: number;
    // 行数
      private readonly rows: number = 10;
    // 列数
      private readonly cols: number = 10;
     // 游戏界面
      private readonly container: HTMLDivElement = document.querySelector('.pop_star');
        // 目标分数的dom
      private readonly targetScoreDom: HTMLDivElement = document.querySelector('.target_score');
      // 当前分数的dom
      private readonly currentScoreDom: HTMLDivElement = document.querySelector('.current_score');
      // 目标分数
      private targetScore: number = 2000;
      // 当前分数
      private currentScore: number = 0;
      // 目标分数dom的提示信息
      private targetDomTips: string = '目标分数:';
      // 当前分数dom的提示信息
      private currentDomTips: string = '当前分数:';

    constructor () {
        // 初始化基础信息
        this.initBase();
    }
    /**
   * 初始化棋盘上的分数、棋盘宽高等等
   */
    private initBase(): void {
        this.container.style.width = this.cols * this.starWidth + 'px';
        Game.changeScore(this.currentScoreDom, this.currentScore, this.currentDomTips);
        Game.changeScore(this.targetScoreDom, this.targetScore, this.targetDomTips);
    }
     /**
   * 静态方法:改变分数
   * @param dom 要改变分数的dom
   * @param score 改变的分数
   * @param tips 提示信息
   */
    private static changeScore(dom: HTMLDivElement, score: number, tips: string): void {
        dom.innerHTML = tips + score
    }
}

初始化二维数组

初始化二维数组,我们首先需要定义二维数组

    // 星星的二维数组集合
    private square: HTMLDivElement [][] = [];
    constructor () {
        // 初始化基础信息
        this.initBase();
        // 初始化二维数组、除此渲染游戏面板
        this.initSquare();
    }
    private initSquare(): void {
        // 根据定义的行和列进行渲染 
        const {rows, cols} = this;
        // 性能问题,使用DocumentFragment
        let fragment: DocumentFragment = document.createDocumentFragment();
        for (let i = 0; i < rows; i++) {
            this.square[i] = [];
            for (let j = 0; j < cols; j++) {
                // 创建对应位置星星的dom,并在square这个二维数组存储起来
                let block: HTMLDivElement = this.createBlock(Game.getRandomNumber(), i, j);
                // 向sqaure中的指定位置添加该星星
                this.square[i][j] = block;
                fragment.append(block)
            }
        }
        // 将fragment添加到容器中
        this.container.append(fragment);
        // 重新设置星星的相关样式
        this.refresh()
    }
    /**
   * 静态函数,生成0-4的随机数字来表示星星的type
   */
    private static getRandomNumber(): number {
        return Math.floor(Math.random() * 5)
    }
    /**
   * 创建星星对象
   * @param type 星星的类型
   * @param row  星星的行
   * @param col  星星的列
   */
    private createBlock(type: number, row: number, col: number): HTMLDivElement {
        let block: Star = new Star(type, row, col);
        // 设置宽度
        this.starWidth = block.width;
        // 返回这个位置星星的dom
        return block.createBlock();
    }

refresh函数的复用

  refresh函数是用来对星星进行一些背景和位置的刷新,所以这个函数可以抽成一个方法用来对每一次星星位置的变化做出刷新操作。

  /**
   * 渲染星星,被多次调用
   */
    private refresh(): void {
        //使用square.length是因为我们在消除星星的过程中,会出现某一列的星星被消灭光,所以需要去对应的square的长度
        let {square} = this, rows = square.length;
        for (let row = 0; row < rows; row++) {
            // 去square[row].length作为col也是因为在消除途中必然会出现某一列不满的情况
            let cols = square[row].length;
            for (let col = 0; col < cols; col++) {
                let curSquare: any = square[row][col];
                // 严格判断
                if (curSquare === null) {
                    continue;
                }
            // 因为这个函数会调用多次,所以我们在每次渲染时重新分配行和列、以及在dom上的坐标
            curSquare.row = row;
            curSquare.col = col;
            // 给一个渐变
            curSquare.style.transition = "left 0.3s, bottom 0.3s";
            // 确定每个星星的位置
            curSquare.style.left = curSquare.col * this.starWidth + "px";
            curSquare.style.bottom = curSquare.row * this.starWidth + "px";
            // 根据星星的类型渲染图片
            curSquare.style.backgroundImage = `url(./img/${curSquare.type}.png)`;
            curSquare.style.backgroundSize = "cover";
            // 让星星变小点,方便以后移入闪烁
            curSquare.style.transform = "scale(0.95)";
      }
    }
  }

绑定事件

  监听分为鼠标移入的监听和鼠标单击的监听。当鼠标移入时星星闪烁;当鼠标单击的时候,选中的星星被消除,然后对应的星星做移动操作

    constructor() {
        // 初始化基础信息
        this.initBase();
        // 初始化二维数组
        this.initSquare();
        // 绑定监听
        this.addListener();
    }
    /**
    * 绑定监听
    */
    private addListener(): void {
        this.container.addEventListener('mouseover', this.mouseOverListener);
        this.container.addEventListener('click', this.mouseClickListener);
    }
   /**
    * 移除监听
    */
    private removeListener(): void {
        this.container.removeEventListener('mouseover', this.mouseOverListener);
        this.container.removeEventListener('click', this.mouseClickListener);
    }
  /**
   * 鼠标移入事件的监听
   * @param event
   */
    private mouseOverListener = (event: MouseEvent): void => {
        if ((event.target as any).className === 'star') {
        this.handleMouseOver(event.target)
        }
    };
  /**
   * 点击事件的监听
   * @param event
   */
    private mouseClickListener = (event: MouseEvent): void => {
        if ((event.target as any).className === 'star') {
            this.handleMouseClick(event.target)
        }
    };

鼠标移入的处理

鼠标移入时,我们需要三个功能:

  1. 周围相同的星星闪烁,并且计算出选中的星星的分数
  2. 移出时星星直到下一个选中效果出现的时候才会还原
  3. 闪烁的星星必须要大于1个

递归找选中的星星

    // 选中的星星
    private choosedStar: HTMLDivElement[] = [];
    private handleMouseOver(target: EventTarget): void {
        if (target === null) {// 严谨判断
            return;
        }
        // 每次移入的时候,清空choosedStar
        this.choosedStar = [];
        // 找到与当前移入时type相同的星星,并将其推入已选中的星星数组
        this.chooseStar(target, this.choosedStar);
        ...
    }
   /**
   * 递归找出相连的星星
   * @param chooseDom 相对位置的星星
   * @param choosedArr 相连的星星数组
   */
    private chooseStar(chooseDom: any, choosedArr: HTMLDivElement[]): void {
        // 递归的出口:找不到dom
        if (chooseDom === null) {
            return
        }
        // 推入选中的数组
        choosedArr.push(chooseDom);
        // 向左
        if (chooseDom.col > 0 && // 往左边走,当前的列数至少要大于0
            this.square[chooseDom.row][chooseDom.col - 1] && // 左边必须有星星
            (this.square[chooseDom.row][chooseDom.col - 1] as any).type === chooseDom.type && // 左边的元素type和选中的type一样
            !~choosedArr.indexOf(this.square[chooseDom.row][chooseDom.col - 1]) // 避免重复选取
        ) {
          this.chooseStar(this.square[chooseDom.row][chooseDom.col - 1], choosedArr)
        }
        // 往右
        if (chooseDom.col < this.cols - 1 && // 往右边走,当前的列数不能大于cols - 1(这样才能保证可以向右边走)
            this.square[chooseDom.row][chooseDom.col + 1] && // 右边必须有星星
            (this.square[chooseDom.row][chooseDom.col + 1] as any).type === chooseDom.type && // 右边的元素type和选中的type一样
            !~choosedArr.indexOf(this.square[chooseDom.row][chooseDom.col + 1]) // 避免重复选取
        ) {
          this.chooseStar(this.square[chooseDom.row][chooseDom.col + 1], choosedArr)
        }
        // 往上
        if (chooseDom.row > 0 && // 往上边走,行数至少大于0
            this.square[chooseDom.row - 1][chooseDom.col] && // 上边必须有星星
            (this.square[chooseDom.row - 1][chooseDom.col] as any).type === chooseDom.type && // 上边的元素type和选中的type一样
            !~choosedArr.indexOf(this.square[chooseDom.row - 1][chooseDom.col]) // 避免重复选取
        ) {
          this.chooseStar(this.square[chooseDom.row - 1][chooseDom.col], choosedArr)
        }
        // 往下
        if (chooseDom.row < this.rows - 1 && // 往下边走,行数不能大于rows-1
            this.square[chooseDom.row + 1][chooseDom.col] && // 下边必须有星星
            (this.square[chooseDom.row + 1][chooseDom.col] as any).type === chooseDom.type && // 下边的星星类型必须和当前的一样
            !~choosedArr.indexOf(this.square[chooseDom.row + 1][chooseDom.col])//避免重复选取
        ) {
          this.chooseStar(this.square[chooseDom.row + 1][chooseDom.col], choosedArr)
        }
    }

选中星星的闪烁效果

    // 星星闪烁的计时器
    private flickTimer: any = null;
    private handleMouseOver(target: EventTarget): void {
        ...
        // 如果星星数组长度小于2,则不做处理
        if (this.choosedStar.length < 2) {
            this.choosedStar = [];
            return
        }
        // 如果星星数大于2,则让星星闪烁,并计算已选中的分数
        this.flickStar();
    }
   /**
    * 使星星闪烁的函数
    */
    private flickStar(): void {
        // 每次移入并闪烁之前将原来闪烁的星星恢复原状
        this.clearFlick();
        // 让选中的星星闪烁,即周期性地改变已选中星星的样式
        let {choosedStar} = this, {length} = choosedStar, num: number = 0;
        this.flickTimer = setInterval(() => {
            for (let i = 0; i < length; i++) {
                let div: HTMLDivElement = choosedStar[i];
                div.style.border = "3px solid #BFEFFF";
                // 这里我们用了一个技巧,就是0.05的周期性的次方数和0.90相加得到一个介于0.85——0.95的大小的星星
                div.style.transform = `scale(${0.90 + Math.pow(-1, num) * 0.05})`
            }
            // 每个interval增加num
            num++;
        }, 300)
    }

计算选中的分数

    // 递增分
    private readonly stepScore: number = 10;
    // 基础分
    private readonly baseScore: number = 5;
    // 选择计算分数的dom
    private selectScoreDom: HTMLDivElement = document.querySelector('.selecting_score');
    // 选择分数
    private selectedScore: number = 0;
    private handleMouseOver(target: EventTarget): void {
        ...
        this.showChoosedScore();
    }
   /**
    * 计算选中的分数并显示的函数
    */
    private showChoosedScore(): void {
        let {choosedStar, baseScore, stepScore, selectScoreDom} = this, {length} = choosedStar, score = 0;
        // 计算已选中的分数
        for (let i = 0; i < length; i++) {
            score += baseScore + stepScore * i;
        }
        // 严谨判断
        if (score <= 0) {
            return;
        }
        // 样式设置
        this.selectedScore = score;
        // 突然出现
        selectScoreDom.style.opacity = '1';
        selectScoreDom.style.transition = null;
        selectScoreDom.innerHTML = `${length}${this.selectedScore}分`;
        // 一秒中之后逐渐消失
        setTimeout(function () {
            selectScoreDom.style.opacity = '0';
            selectScoreDom.style.transition = 'opacity 1s';
        }, 1000)
  }

鼠标点击时的处理

计算总分数

   /**
    * 点击星星的处理函数
    * @param target 点中的星星
    */
    private handleMouseClick(target: EventTarget): void {
        let {currentScoreDom, flickTimer, currentDomTips, square, choosedStar, selectedScore, container} = this, {length} = choosedStar;
        // 计算总分数
        this.totalScore += selectedScore;
        Game.changeScore(currentScoreDom, this.totalScore, currentDomTips);
        // 在星星移动期间设置锁,如果锁存在那么就不允许在星星移动期间进行其他操作
        if (this.isAnimating || length < 2) {
            return
        }
        ...
    }

选中的星星进行消除

   /**
    * 点击星星的处理函数
    * @param target 点中的星星
    */
    private handleMouseClick(target: EventTarget): void => {
        ...
        // container移除dom,square移除dom
        for (let i = 0; i < length; i++) {
            setTimeout(function () {
                // 找到对应的星星
                let star = <any>choosedStar[i];
                // 将sqaure对应位置的星星置空
                square[star.row][star.col] = null;
                // 在游戏容器中移除该星星
                container.removeChild(choosedStar[i]);
            }, i * 100);
        }
        // 清除定时器
        if (flickTimer !== null) {
            clearInterval(flickTimer)
        }
    }

消除选中的星星之后的移动

   /**
    * 点击星星的处理函数
    * @param target 点中的星星
    */
    private handleMouseClick(target: EventTarget): void => {
        ...
        // 下落
        setTimeout(() => {
            this.doMove();
        }, length * 100)
    }
  /**
   * 星星下落的函数
   */
    private doMove(): void {
        let {rows, cols, square} = this;
        // 纵向移动
        for (let col = 0; col < cols; col++) {
            let pointer: number = 0; // 设置一个指针
            // pointer和row一起增加,pointer遇到第一个row为null的位置停止移动,j仍然增加,
            // 当row遇到第一个非null的位置时,进行pointer和row位置的赋值操作
            for (let row = 0; row < rows; row++) {
            // 遇到一个不是null的位置,则pointer+1,直到遇到为null的位置停止增加
                // 那么当前pointer的位置就表示了遇到的第一个为null的位置的行数
                if (square[row][col] !== null) {
                    // 并且此时pointer和row并不是指向同一个星星的进行移动
                    if (row !== pointer) {
                        // 将row遍历到的非null的square[col][row]赋值给pointer遍历到的为null的square[pointer][row]
                        square[pointer][col] = square[row][col];
                        // 更新当前dom的row
                        (square[row][col] as any).row = pointer;
                        // 将掉落的星星置null
                        square[row][col] = null;
                    }
                        // 指针pointer在非null的时候增加
                        pointer++;
                }
            }
        }
        // 横向移动,若最下面的一行某一列的星星为null的话,则表示当前行的当前列已为空,则删除每一行的当前列项
        for (let col = 0; col < square[0].length;) {
            // 最下面一行的某一列为null时,删除当前列
            if (square[0][col] == null) {
                for (let row = 0; row < rows; row++) {
                    square[row].splice(col, 1);
                }
                continue;
            }
            // 因为删除某一列之后,后面的列会向前移动一列,
            // 如果我们在for循环的作用域里面增加col,则会跳过移动之后的列的检查,所以我们需要在在当前列不为空的时候进行col++
            col++;
        }
        // 每次移动完成之后,进行星星的重新渲染
        this.refresh();
    }

判断游戏结束

   /**
    * 点击星星的处理函数
    * @param target 点中的星星
    */
    private handleMouseClick(target: EventTarget): void => {
        ...
        // 下落
        setTimeout(() => {
            ...
            // 每次移动完成之后判断游戏是否结束
            setTimeout(() => {
                let finished: boolean = this.checkFinish();
                if (finished) {
                // 游戏结束之后清空container和square
                    this.clear();
                    // 游戏胜利,继续游戏,增加关卡难度和目标分数
                    if (this.totalScore >= this.targetScore) {
                        alert("恭喜获胜");
                        this.targetScore += this.level * this.stepTargetScore;
                        this.level++;
                    // 游戏失败,关卡难度和目标分数重置 
                    } else {
                        alert("游戏失败");
                        this.targetScore = 2000;
                        this.level = 0;
                        this.totalScore = 0;
                    }
                    // 打开锁
                    this.isAnimating = false;
                    // 新游戏
                    new Game();
                } else {
                    // 游戏未结束,将选中的星星清空并打开锁
                    choosedStar = [];
                    this.isAnimating = false;
                }
            }, 300 + length * 150);
        }, length * 100)
    }
  /**
   * 判断游戏是否结束的函数
   */
    private checkFinish(): boolean {
        // 检查游戏结束的条件是,在剩下的方块中有无相连的相同的方块
        // 获得剩下的行数
        let {square} = this, rows = square.length;
        for (let row = 0; row < rows; row++) {
            // 获得剩下该行的列数
            let cols = square[row].length;
            for (let col = 0; col < cols; col++) {
            // 设置当前的一个变量,表示相连的数组
                let temp: HTMLDivElement[] = [];
                this.chooseStar(square[row][col], temp);
                // 如果有连着的则游戏还未结束
                if (temp.length > 1) {
                    return false;
                }
            }
        }
        return true;
    }
   /**
    * 清空container和square星星的函数
    */
    private clear(): void {
        // 获得剩下的行数
        let {square, container} = this, rows = square.length;
        for (let row = 0; row < rows; row++) {
            // 获得剩下的该行的列数
            let cols = square[row].length;
            for (let col = 0; col < cols; col++) {
                // 严谨判断
                if (square[row][col] === null) {
                    continue;
                }
                // container移除dom
                container.removeChild(square[row][col]);
                // square当前位置置空
                square[row][col] = null;
            }
        }
        // 移除监听
        this.removeListener();
    }

结语

这个消灭星星我分别写了jq版本ts版本。若有bug还请各位大佬指出,都写支持。