使用gsap动画库 + 原生js实现消消乐游戏

1,295 阅读11分钟
前言

最近在我司使用react + gasp实现消消乐游戏,但因为数据更新会与动画操作冲突,可能动画还在操作某个元素,因为代码逻辑没控制好react那边数据更新了然后元素被移除了之类的情况导致线上bug频出,然后就悟出了这种游戏类的应该用原生js直接操作dom才对,所以对代码进行了重构

以下是游戏效果图

2024-07-22 16-15-18.2024-07-22 16_17_23.gif

在此先感谢站内各优秀文章提供实现思路,在下只是一个代码搬运工以及整合方,如若冒犯求放过🙏🙏🙏

正文开始
新建消消乐类

首先肯定是初始化一个类叫做Xiaoxiaole,里面需要这么些东西,接受一个游戏所需容器id并且存起来,如代码所示

import { gsap } from 'gsap'
import { uniq } from 'lodash-es'

interface IOptions {
    containerId: string
    row: number
    col: number
    pieceSize: number
    handleScoreChange?: (score: number) => void
    handleComboChange?: (combo: number) => void
    handleMaxComboChange?: (combo: number) => void
    handleRecordChange?: (list: string[]) => void
    handleSummaryChange?: (list: number[]) => void
    handleGameOver?: () => void
}

type Piece = {
    id: string
    value: number | null
    ele: HTMLDivElement
    rowIndex: number
    colIndex: number
}
type ChessBoard = Piece[][]

// 设置动画一帧持续时间
gsap.defaults({
    duration: 0.25
})

const idPrefix = 'piece-'

const bgColor = ['red', 'green', 'blue', 'orange', 'purple', 'pink']

class Xiaoxiaole {
    // 棋子数据
    chessPieces: number[] = [1, 2, 3, 4, 5, 6]
    // 棋盘数据
    chessBoard: ChessBoard = []
    // 棋盘容器
    container: HTMLElement | null = null
    // 棋盘尺寸-行数
    row: number = 0
    // 棋盘尺寸-列数
    col: number = 0
    // 棋子宽高
    pieceSize: number = 0
    // id递增
    index: number = 1
    // 总数
    score: number = 0
    // 连击数
    combo: number = 0
    // 最大连击数
    maxCombo: number = 0
    // 当前选中棋子
    selectedPiece: Piece | null = null
    // 是否正在执行动画
    running: boolean = false
    // 记录当前消除棋子的值,坐标和时间
    record: string[] = []
    // 记录消除各棋子个数
    summary: number[] = []
    // 移动端触碰初始位置
    originPos = {
        startX: 0,
        startY: 0
    }
    
    handleGameOver = () => {
        alert('游戏结束')
        this.resetData()
    }
    handleScoreChange = (value: number) => console.log('当前分数:', value)
    handleComboChange = (value: number) => console.log('当前连击数:', value)
    handleMaxComboChange = (value: number) => console.log('最大连击数:', value)
    handleRecordChange = (list: string[]) => console.log('当前消除记录:', list)
    handleSummaryChange = (list: number[]) => console.log('当前棋子消除个数汇总:', list)
    
    constructor({
        containerId,
        row,
        col,
        pieceSize,
        handleGameOver,
        handleScoreChange,
        handleComboChange,
        handleMaxComboChange,
        handleRecordChange,
        handleSummaryChange
    }: IOptions) {
        handleGameOver && (this.handleGameOver = handleGameOver)
        handleScoreChange && (this.handleScoreChange = handleScoreChange)
        handleComboChange && (this.handleComboChange = handleComboChange)
        handleMaxComboChange && (this.handleMaxComboChange = handleMaxComboChange)
        handleRecordChange && (this.handleRecordChange = handleRecordChange)
        handleSummaryChange && (this.handleSummaryChange = handleSummaryChange)
        this.container = document.getElementById(containerId)
        this.row = row
        this.col = col
        this.pieceSize = pieceSize
        this.initChessBoard(row, col)
    }
}

然后,就肯定需要一堆设置数据的方法,如代码所示

// 设置游戏得分
setScore = (value: number) => {
    this.score = value
    this.handleScoreChange(value)
}

// 设置连接数
setCombo = (value: number) => {
    this.combo = value
    this.handleComboChange(value)

    if (value > this.maxCombo) {
        this.setMaxCombo(value)
    }
}

// 设置最大连击数
setMaxCombo = (value: number) => {
    this.maxCombo = value
    this.handleMaxComboChange(value)
}

// 设置消除记录,包括值,坐标和时间
setRecord = (list: number[][]) => {
    const temp = list.map(v => {
        const value = this.chessBoard[v[0]][v[1]].value || 0
        this.summary[value] = (this.summary[value] || 0) + 1
        return `${value}${v[0]}${v[1]}${Date.now()}`
    })
    this.record = [...this.record, ...temp]
    this.handleRecordChange(this.record)
    this.handleSummaryChange(this.summary)
}

// 重置游戏数据
resetData = () => {
    this.chessBoard.flat().forEach(v => {
        if (isMobile) {
            v.ele.removeEventListener('touchstart', this.handleTouchStart)
            v.ele.removeEventListener('touchmove', this.handleTouchMove)
            v.ele.removeEventListener('touchend', this.handleTouchEnd)
        } else {
            v.ele.removeEventListener('click', this.handleClick)
        }
        this.container?.removeChild(v.ele)
    })
    this.chessBoard = []
    this.summary = []
    this.setRecord([])
    this.index = 1
    this.setScore(0)
    this.setCombo(0)
    this.setMaxCombo(0)
    this.running = false
    this.originPos = {
        startX: 0,
        startY: 0
    }
}
初始化棋盘

基本的东西都准备好了,接下来就可以初始化棋盘了,需要注意的初始化的时候同一行或同一列都不应连续生成3个一样的,毕竟开始就能消除的话会认为有bug...并且初始化之后要检查一下是否是死局,如果是死局的话要重新初始化一遍,直到没有死局为止

// 初始化棋盘
async initChessBoard(row: number, col: number) {
    this.resetData()
    this.running = true
    const temp = []
    for (let i = 0; i < row; i++) {
        temp[i] = new Array(col)
        for (let j = 0; j < col; j++) {
            let value = this.getRandomPiece()
            // 纵向不能生成连续3个一样的棋子
            while (i > 1 && temp[i - 1][j]?.value === value && temp[i - 2][j]?.value === value) {
                value = this.getRandomPiece()
            }
            // 横向不能生成连续3个一样的棋子
            while (j > 1 && temp[i][j - 1]?.value === value && temp[i][j - 2]?.value === value) {
                value = this.getRandomPiece()
            }
            temp[i][j] = this.createPiece(value, i, j)
        }
    }
    this.chessBoard = temp as ChessBoard
    if (this.checkGameOver()) {
        this.initChessBoard(row, col)
    } else {
        await this.playInitAnimation()
    }
    this.running = false
}

// 初始化动画
playInitAnimation = async (): Promise<void> => {
    const [row, col] = [this.row, this.col]
    return new Promise(resolve => {
        for (let i = 0; i < row; i++) {
            for (let j = 0; j < col; j++) {
                gsap.fromTo(
                    `#${this.chessBoard[i][j].id}`,
                    {
                        opacity: 0
                    },
                    {
                        opacity: 1,
                        top: this.pieceSize * i,
                        delay: (row + (col ? col : 0) - (j + i)) * 0.1,
                        ease: 'bounce.out',
                        onComplete: () => {
                            if (i === 0 && j === 0) {
                                resolve()
                            }
                        }
                    }
                )
            }
        }
    })
}
生成棋子
// 生成随机棋子值
getRandomPiece(): number {
    const randomIndex = Math.floor(Math.random() * this.chessPieces.length)
    return this.chessPieces[randomIndex]
}

createPiece = (value: number, rowIndex: number, colIndex: number) => {
    const id = idPrefix + this.index.toString()
    const ele = document.createElement('div')
    ele.innerHTML = value.toString()
    ele.setAttribute('id', id)
    ele.setAttribute(
        'style',
        `display: inline-flex; align-items: center; justify-content:center; width: ${this.pieceSize}px; height: ${
            this.pieceSize
        }px; position: absolute; left: ${colIndex * this.pieceSize}px; top: -${this.pieceSize}px; background: ${
            bgColor[value - 1]
        }; border: 2px solid #fff; color: #fff`
    )

    if (isMobile) {
        ele.addEventListener('touchstart', this.handleTouchStart)
        ele.addEventListener('touchmove', this.handleTouchMove)
        ele.addEventListener('touchend', this.handleTouchEnd)
    } else {
        ele.addEventListener('click', this.handleClick)
    }
    this.container?.appendChild(ele)
    this.index++
    return { id, value, ele, rowIndex, colIndex }
}

这里需要区分下移动端还是pc端,毕竟两端的交互方式是不一样的,可以在npm随便找个库抄下代码即可

const isAndroid = (): boolean => Boolean(navigator.userAgent.match(/Android/i))
const isIos = (): boolean => Boolean(navigator.userAgent.match(/iPhone|iPad|iPod/i))
const isOpera = (): boolean => Boolean(navigator.userAgent.match(/Opera Mini/i))
const isWindows = (): boolean => Boolean(navigator.userAgent.match(/IEMobile/i))

const isMobile = Boolean(isAndroid() || isIos() || isOpera() || isWindows())

接下来是各种事件的处理,pc的话比较简单,每次点击判断棋子交换后是否能进行消除就行

// pc
handleClick = async (e: Event) => {
    const target = e.target as HTMLDivElement
    const colIndex = parseInt(target.style.left) / this.pieceSize
    const rowIndex = parseInt(target.style.top) / this.pieceSize
    const piece = this.chessBoard[rowIndex][colIndex]
    await this.handlePieceClick({ target, colIndex, rowIndex, piece })
}

移动端因为是触碰长安拖动的交互,因此需要判断拖动方向进行交换棋子的处理

// mobile
handleTouchStart = async (e: TouchEvent) => {
    // 记录起始触摸位置
    const touch = e.touches[0]
    const startX = touch.pageX
    const startY = touch.pageY

    this.originPos.startX = startX
    this.originPos.startY = startY

    const target = e.target as HTMLDivElement
    const colIndex = parseInt(target.style.left) / this.pieceSize
    const rowIndex = parseInt(target.style.top) / this.pieceSize
    const piece = this.chessBoard[rowIndex][colIndex]

    await this.handlePieceClick({ target, colIndex, rowIndex, piece })
}

handleTouchMove = async (e: TouchEvent) => {
    if (!this.originPos.startX || !this.originPos.startY || !this.selectedPiece || this.running) {
        return
    }
    const target = e.target as HTMLDivElement
    const colIndex = parseInt(target.style.left) / this.pieceSize
    const rowIndex = parseInt(target.style.top) / this.pieceSize
    // 获取当前触摸位置
    const touch = e.touches[0]
    const currentX = touch.pageX
    const currentY = touch.pageY

    // 计算水平和垂直位移
    const deltaX = currentX - this.originPos.startX
    const deltaY = currentY - this.originPos.startY

    // 判断手指移动方向(左右还是上下)
    let direction = null
    if (Math.abs(deltaX) > Math.abs(deltaY)) {
        direction = deltaX > 0 ? 'right' : 'left'
    } else {
        direction = deltaY > 0 ? 'bottom' : 'top'
    }
    let _row = rowIndex
    let _col = colIndex
    switch (direction) {
        case 'left':
            _col = _col > 0 ? _col - 1 : _col
            break
        case 'right':
            _col = _col < this.col - 1 ? _col + 1 : _col
            break
        case 'top':
            _row = _row > 0 ? _row - 1 : _row
            break
        case 'bottom':
            _row = _row < this.row - 1 ? _row + 1 : _row
            break
    }
    if (!(_row === rowIndex && _col === colIndex)) {
        const piece = this.chessBoard[_row][_col]
        const target = piece.ele

        await this.handlePieceClick({ target, colIndex: _col, rowIndex: _row, piece })
    }
    this.originPos = {
        startX: 0,
        startY: 0
    }
}

handleTouchEnd = () => {
    this.originPos = {
        startX: 0,
        startY: 0
    }
}

然后就是具体选中棋子的处理函数

// 处理棋子点击事件
handlePieceClick = async ({
    target,
    colIndex,
    rowIndex,
    piece
}: {
    target: HTMLElement
    colIndex: number
    rowIndex: number
    piece: Piece
}) => {
    // 如果动画操作正在执行,不处理
    if (this.running) {
        return
    }

    // 点击相同棋子,取消选中
    if (this.selectedPiece?.id === piece.id) {
        this.selectedPiece.ele.style.borderColor = '#fff'
        this.selectedPiece = null
        return
    }
    
    // 当前没选中或者已选中情况下再点击不是同一行或同一列的均选中最后一次点击的棋子为当前选中棋子
    if (
        !this.selectedPiece ||
        (rowIndex !== this.selectedPiece.rowIndex && colIndex !== this.selectedPiece.colIndex) ||
        (rowIndex === this.selectedPiece.rowIndex && Math.abs(colIndex - this.selectedPiece.colIndex) > 1) ||
        (colIndex === this.selectedPiece.colIndex && Math.abs(rowIndex - this.selectedPiece.rowIndex) > 1)
    ) {
        this.selectedPiece && (this.selectedPiece.ele.style.borderColor = '#fff')
        target.style.borderColor = '#000'
        this.selectedPiece = piece
        return
    }
    
    // 假装动画开始
    this.running = true
    
    // 交换棋子
    this.swapPiece([this.selectedPiece.rowIndex, this.selectedPiece.colIndex], [rowIndex, colIndex])
    
    // 检测交换后是否能进行消除
    const matchedPieces: number[][] = this.checkMatchedPieces([
        [this.selectedPiece.rowIndex, this.selectedPiece.colIndex],
        [rowIndex, colIndex]
    ])
    
    const canRemove = matchedPieces.length > 0
    if (!canRemove) {
        // 如果不能消除的话需要把棋子换回来
        this.swapPiece([this.selectedPiece.rowIndex, this.selectedPiece.colIndex], [rowIndex, colIndex])
    }
 
    // 执行交换动画 
    await this.playSwapAnimation(
        [this.selectedPiece.rowIndex, this.selectedPiece.colIndex],
        [rowIndex, colIndex],
        !canRemove
    )
    
    // 如果能进行消除的话执行对应一系列的操作
    if (canRemove) {
        await this.checkAndRemoveMatchesAt(
            [
                [this.selectedPiece.rowIndex, this.selectedPiece.colIndex],
                [rowIndex, colIndex]
            ],
            matchedPieces
        )
    }
    
    // 消除之后还需要把选中状态重置
    this.selectedPiece.ele.style.borderColor = '#fff'
    this.selectedPiece = null
    this.running = false
}

以上点击棋子处理函数基本就是整个消消乐游戏的流程了,下面再来看看具体消除的过程是怎么实现的

消除流程逻辑实现
交换棋子

首先是交换棋子,其实就是把棋盘相邻两个棋子的引用交换一下而已,至于交换棋子动画其实就是把两个元素的top值或者left值交换而已

// 交换棋子
swapPiece([row1, col1]: [number, number], [row2, col2]: [number, number]) {
    const temp = this.chessBoard[row1][col1]
    this.chessBoard[row1][col1] = { ...this.chessBoard[row2][col2], rowIndex: row1, colIndex: col1 }
    this.chessBoard[row2][col2] = { ...temp, rowIndex: row2, colIndex: col2 
}

// 交换棋子动画,棋子交换后无法消除需要恢复原位
playSwapAnimation = (
    [row1, col1]: [number, number],
    [row2, col2]: [number, number],
    reverse = true
): Promise<void> | undefined => {
    return new Promise(async resolve => {
        if (!this.selectedPiece) {
            resolve()
            return
        }

        const ele1 = document.querySelector(`#${this.chessBoard[row1][col1].id}`) as HTMLDivElement
        const originLeft1 = ele1 ? parseInt(ele1.style?.left) : 0
        const originTop1 = ele1 ? parseInt(ele1.style?.top) : 0

        const ele2 = document.querySelector(`#${this.chessBoard[row2][col2].id}`) as HTMLDivElement
        const originLeft2 = ele2 ? parseInt(ele2.style?.left) : 0
        const originTop2 = ele2 ? parseInt(ele2.style?.top) : 0

        if (row1 === row2) {
            await new Promise<void>(resolve => {
                gsap.to(ele1, {
                    left: originLeft2
                })
                gsap.to(ele2, {
                    left: originLeft1,
                    onComplete: () => resolve()
                })
            })
        } else if (col1 === col2) {
            await new Promise<void>(resolve => {
                gsap.to(ele1, {
                    top: originTop2
                })
                gsap.to(ele2, {
                    top: originTop1,
                    onComplete: () => resolve()
                })
            })
        }

        if (reverse) {
            if (row1 === row2) {
                await new Promise<void>(resolve => {
                    gsap.to(ele1, {
                        left: originLeft1
                    })
                    gsap.to(ele2, {
                        left: originLeft2,
                        onComplete: () => resolve()
                    })
                })
            } else if (col1 === col2) {
                await new Promise<void>(resolve => {
                    gsap.to(ele1, {
                        top: originTop1
                    })
                    gsap.to(ele2, {
                        top: originTop2,
                        onComplete: () => resolve()
                    })
                })
            }
        }
        resolve()
    })
}

消除棋子

交换之后就是检查消除了,大致流程是交换后找出相邻能进行消除的棋子,如果能进行消除,执行消除动画,棋子消除之后上方的棋子需要往下掉落,并且生成新的棋子对棋盘空缺位置进行填充,然后继续重复检测消除逻辑,知道不能消除位置

// 检查消除
async checkAndRemoveMatchesAt(pos: number[][], matchedPieces?: number[][]) {
    // 检查是否能进行消除的棋子
    const matches: number[][] =
        Array.isArray(matchedPieces) && matchedPieces.length > 0 ? matchedPieces : this.checkMatchedPieces(pos)

    // 没有能消除的话检查是否游戏结束
    if (matches.length < 1) {
        this.setCombo(0)
        this.checkGameOver() && this.handleGameOver()
        return
    }

    // 如果是连续消除的话需要记录连击数
    if (!matchedPieces) {
        this.setCombo(this.combo + 1)
    }

    // 计算得分,查找消除棋子的 时候可能会出现重复坐标,所以需要手动去重,去重后才是真正消除的棋子
    const arr = uniq(matches.map(v => v.join(','))).map((v: string) => v.split(',').map(v => Number(v)))
    this.setScore((this.score += arr.length))
    this.setRecord(arr)

    // 消除棋子,执行消除动画
    for (const [row, col] of matches) {
        this.chessBoard[row][col].value = null
    }
    await this.playRemoveAnimation?.(matches)

    // 处理上方棋子掉落 
    const movePieces = await this.movePiecesDown()
    // 处理填充棋子 
    const refillPieces = await this.refillAndCheck()

    const movedPos = movePieces.concat(refillPieces)

    // 消除后重新检查是否能继续消除
    if (movedPos.length > 0) {
        await this.checkAndRemoveMatchesAt(movedPos)
    } else {
        // 没有能消除的话检查是否游戏结束
        this.setCombo(0)
        this.checkGameOver() && this.handleGameOver()
    }
}

检查是否能进行消除需要围绕交换的棋子进行横向和纵向检查,找到相邻有3个或以上值一样的棋子就证明能进行消除

// 检查棋子
checkMatchedPieces(pos: number[][]): number[][] {
    let matches: number[][] = []
    for (const [row, col] of pos) {
        // 横向匹配
        const cols = this.checkMatch(row, col, true)
        // 纵向匹配
        const rows = this.checkMatch(row, col, false)
        matches = matches.concat(cols, rows)
    }
    return matches
}

// 检查单个棋子
checkMatch(row: number, col: number, horizontal: boolean) {
    const list = this.chessBoard
    const matches = [[row, col]]
    const current = list[row][col].value
    let i = 1
    if (horizontal) {
        // 往左遍历
        while (col - i >= 0 && list[row][col - i].value === current) {
            matches.push([row, col - i])
            i++
        }
        i = 1
        // 往右遍历
        while (col + i < list[row].length && list[row][col + i].value === current) {
            matches.push([row, col + i])
            i++
        }
    } else {
        // 往上遍历
        while (row - i >= 0 && list[row - i][col].value === current) {
            matches.push([row - i, col])
            i++
        }
        i = 1
        // 往下遍历
        while (row + i < list.length && list[row + i][col].value === current) {
            matches.push([row + i, col])
            i++
        }
    }
    return matches.length >= 3 ? matches : []
}
棋子掉落

找到消除棋子之后,上方的棋子需要往下移动到对应的位置,也就是掉落行为,至于掉落动画就是通过调整元素的top值实现

// 向下移动棋子
async movePiecesDown() {
    const movedPos: number[][] = []
    for (let col = this.chessBoard[0].length - 1; col >= 0; col--) {
        let nullCount = 0
        for (let row = this.chessBoard.length - 1; row >= 0; row--) {
            const value = this.chessBoard[row][col].value
            if (value === null) {
                nullCount++
            } else if (nullCount > 0) {
                this.chessBoard[row + nullCount][col] = {
                    ...this.chessBoard[row][col],
                    rowIndex: row + nullCount,
                    colIndex: col
                }
                this.chessBoard[row][col] = {
                    ...this.chessBoard[row][col],
                    value: null,
                    rowIndex: row,
                    colIndex: col
                }
                movedPos.push([row + nullCount, col, nullCount])
            }
        }
    }
    if (movedPos.length > 0) {
        await this.playDownAnimation(movedPos)
    }
    return movedPos
}

// 执行上方棋子下落动画
playDownAnimation = (pos: number[][]): Promise<void> => {
    return new Promise(resolve => {
        const len = pos.length
        for (let i = 0; i < len; i++) {
            const [row, col, nullCount] = pos[i]
            const ele = document.querySelector(`#${this.chessBoard[row][col].id}`) as HTMLDivElement
            const originTop = ele ? parseInt(ele.style?.top) : 0
            gsap.to(`#${this.chessBoard[row][col].id}`, {
                top: originTop + nullCount * this.pieceSize,
                ease: 'bounce.out',
                onComplete: () => {
                    if (i === len - 1) {
                        resolve()
                    }
                }
            })
        }
    })
}
填充棋子

上方棋子掉落之后需要生成新的新的棋子填充棋盘

// 重新填充和检查棋子
async refillAndCheck() {
    const movedPos: number[][] = []
    for (let row = 0; row < this.chessBoard.length; row++) {
        for (let col = 0; col < this.chessBoard[row].length; col++) {
            if (this.chessBoard[row][col].value === null) {
                const value = this.getRandomPiece()
                this.chessBoard[row][col] = this.createPiece(value, row, col)
                movedPos.push([row, col])
            }
        }
    }

    if (movedPos.length > 0) {
        await this.playFillAnimation(movedPos)
    }

    return movedPos
}

// 执行补充棋子下落动画
playFillAnimation = (pos: number[][]): Promise<void> => {
    const len = pos.length
    return new Promise(resolve => {
        for (let i = 0; i < len; i++) {
            const [row, col] = pos[i]
            const id = `${this.chessBoard[row][col].id}`
            gsap.fromTo(
                `#${id}`,
                {
                    opacity: 0
                },
                {
                    opacity: 1,
                    top: this.pieceSize * row,
                    ease: 'bounce.out',
                    onComplete: () => {
                        if (i === len - 1) {
                            resolve()
                        }
                    }
                }
            )
        }
    })
}
检测游戏结束

最后还差一个如果检测游戏结束的方法,脑子不够用想不出高效的算法,只能把所有想到的情况罗列出来处理一遍,欢迎脑子好的jym给出更高效的方法

// 检测游戏是否结束
checkGameOver() {
    for (let i = 0; i < this.row; i++) {
        // 横向检查
        for (let j = 0; j < this.col; j++) {
            let count = 1
            const max = j + 4 < this.col ? j + 4 : this.col
            for (let k = j + 1; k < max; k++) {
                //
                if (this.chessBoard[i][j].value === this.chessBoard[i][k].value) {
                    count++
                }
            }
            // 满足 [x] [x] [*] [x] 或者 [x] [*] [x] [x]
            if (count >= 3) {
                return false
            }

            if (j < this.col - 2) {
                // 满足
                // [x] [*] [*]
                // [*] [x] [x]
                // [x] [*] [*]
                if (
                    this.chessBoard[i][j + 1].value === this.chessBoard[i][j + 2].value &&
                    (this.chessBoard[i][j + 1].value === (i > 0 && this.chessBoard[i - 1][j].value) ||
                        this.chessBoard[i][j + 1].value === (i < this.row - 1 && this.chessBoard[i + 1][j].value))
                ) {
                    return false
                }

                // 满足
                // [*] [x] [*]
                // [x] [*] [x]
                // [*] [x] [*]
                if (
                    this.chessBoard[i][j].value === this.chessBoard[i][j + 2].value &&
                    (this.chessBoard[i][j].value === (i > 0 && this.chessBoard[i - 1][j + 1].value) ||
                        this.chessBoard[i][j].value === (i < this.row - 1 && this.chessBoard[i + 1][j + 1].value))
                ) {
                    return false
                }

                // 满足
                // [*] [*] [x]
                // [x] [x] [*]
                // [*] [*] [x]
                if (
                    this.chessBoard[i][j].value === this.chessBoard[i][j + 1].value &&
                    (this.chessBoard[i][j].value === (i > 0 && this.chessBoard[i - 1][j + 2].value) ||
                        this.chessBoard[i][j].value === (i < this.row - 1 && this.chessBoard[i + 1][j + 2].value))
                ) {
                    return false
                }
            }
        }
        // 纵向检查
        for (let j = 0; j < this.col; j++) {
            let count = 1
            const max = i + 4 < this.row ? i + 4 : this.row
            for (let k = i + 1; k < max; k++) {
                if (this.chessBoard[i][j].value === this.chessBoard[k][j].value) {
                    count++
                }
            }
            // 满足 或者
            // [x] [x]
            // [x] [*]
            // [*] [x]
            // [x] [x]
            if (count >= 3) {
                return false
            }

            if (i < this.row - 2) {
                // 满足
                // [x] [*] [x]
                // [*] [x] [*]
                // [*] [x] [*]
                if (
                    this.chessBoard[i + 1][j].value === this.chessBoard[i + 2][j].value &&
                    (this.chessBoard[i + 1][j].value === (j > 0 && this.chessBoard[i][j - 1].value) ||
                        this.chessBoard[i + 1][j].value === (j < this.col - 1 && this.chessBoard[i][j + 1].value))
                ) {
                    return false
                }

                // 满足
                // [*] [x] [*]
                // [x] [*] [x]
                // [*] [x] [*]
                if (
                    this.chessBoard[i][j].value === this.chessBoard[i + 2][j].value &&
                    (this.chessBoard[i][j].value === (j > 0 && this.chessBoard[i + 1][j - 1].value) ||
                        this.chessBoard[i][j].value === (j < this.col - 1 && this.chessBoard[i + 1][j + 1].value))
                ) {
                    return false
                }

                // 满足
                // [*] [x] [*]
                // [*] [x] [*]
                // [x] [*] [x]
                if (
                    this.chessBoard[i][j].value === this.chessBoard[i + 1][j].value &&
                    (this.chessBoard[i][j].value === (j > 0 && this.chessBoard[i + 2][j - 1].value) ||
                        this.chessBoard[i][j].value === (j < this.col - 1 && this.chessBoard[i + 2][j + 1].value))
                ) {
                    return false
                }
            }
        }
    }
    return true
}
开始游戏

到此基本上整个消消乐逻辑都完成了,可以开始愉快的玩耍了

const id = 'xiaoxiaole'
const Xiaoxiaole = () => {
    const ref = useRef<Xiaoxiaole>()
    const [maxCombo, setMaxCombo] = useState<number>(0)
    const [score, setScore] = useState<number>(0)
    const [start, setStart] = useState<boolean>(false)

    const startGame = () => {
        setStart(true)
        ref.current = new Xiaoxiaole({
            containerId: id,
            row: 8,
            col: 8,
            pieceSize: 50,
            handleScoreChange: setScore,
            handleMaxComboChange: setMaxCombo
        })
    }

    return (
        <>
            {start ? (
                <div style={{ margin: '100px auto 10px', width: 8 * 50 }}>
                    <div>当前分数:{score}</div>
                    <div>最大连击数:{maxCombo}</div>
                </div>
            ) : (
                <div style={{ margin: '100px auto 10px', textAlign: 'center' }}>
                    <button style={{ border: '1px solid #000', padding: 4 }} onClick={startGame}>
                        游戏开始
                    </button>
                </div>
            )}
            <div
                id={id}
                style={{
                    width: 8 * 50,
                    height: 8 * 50,
                    position: 'relative',
                    margin: `0 auto`,
                    overflow: 'hidden'
                }}
            ></div>
        </>
    )
}
结语

o了,完整代码可点击这里查看,如果测出有bug麻烦在评论区指出来[狗头]