《高阶前端指北》之写一个九宫格拼图插件(下册)

1,585 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第4天,点击查看活动详情

我们接着上节课讲,上节我们讲了如何拆解拼图小游戏,如何设计九宫格,如何实现空间位置,如何移动等关键逻辑。本节重点手把手带大家撸一个标准版TS插件。

有同学私信我说上节课没水平,没有具体方法,比较失望。其实院长只有两把刷子,而且都没毛。这节课就不说废话了,直接上刷子。

插件设计

形参

函数形参就相对灵活了,根据自己的业务需要定制,这里只是简单的搞一个版本,如果你们觉得无聊可以无限扩展。

首先我们定义接口类型:

export interface GridOptions {
  el:string | HTMLElement //挂载节点
  url:string;  // 图片地址
  col:number;  // 行和列 支持非正方形
  row:number;  // 这里扩展非9宫格的能力
  transitionTime?:number;   // 移动动画的时间
  loadComplete?:()=>void;   // 加载完成的回调
  onBlockMove?:(step: number,moveDom:HTMLElement )=>void;   // 移动事件
  success?:(score:number)=>void;  // 拼图成功的回调
  beforeDestroy?: ()=> void;   // 销毁实例之前的回调
  ...
}

以上也是开发一个插件的通用形参,可以作为参考。后面可以根据具体业务场景扩展各种能力。

创建容器

接下来,我们就生成拼图容器,拿到格子数量,计算出容器宽高,赋值背景图等。

private createGridConainer() {
        const { url, col, row, transitionTime } = this.options
        //获取目标坐标信息
        const container = document.createElement('div')
        const { width, height } = this.imageInfo
        this.widthUnit = width / col
        this.heightUnit = height / row
        //设置cssvar 变量
        container?.style.setProperty('--gridWidth', `${width}px`)
        container?.style.setProperty('--gridHeight', `${height}px`)
        container?.style.setProperty('--gridItemWidth', `${this.widthUnit}px`)
        container?.style.setProperty('--gridItemHeight', `${this.heightUnit}px`)
        container?.style.setProperty('--backgroundImage', `url('${url}')`)
        container?.style.setProperty('--transitionTime', `${transitionTime}s`)
        container?.classList.add('puzzle-grid')
        return container
}

创建格子

有了容器我们开始生成拼图格子。我们先拿到每个格子的定位,上述已经讲过实现原理这里不再赘述了。

// 获取每个格子的定位
private getGridPosition(col: number, row: number): gridPostioinOptions {
        const result: gridPostioinOptions = []
        for (let i = 0; i < col; i++) {
            for (let j = 0; j < row; j++) {
                result.push([i, j])
            }
        } 
        return result.sort((a, b) => a[1] - b[1])
}

设置格子图片

然后生成对应的dom,通过左边位置计算格子的背景图位置

// 生成格子dom
private createGridChildren(): DocumentFragment {
        const fragment = document.createDocumentFragment() //使用文档碎片提升性能
        this.gridPostioin.forEach((point, index) => {
            const [x, y] = point
            const div = document.createElement('div')
            div.style.backgroundPosition = `-${x * this.widthUnit}px -${y * this.heightUnit}px`
            div.setAttribute('data-index', String(index))
            div.classList.add('puzzle-grid-item')
            this.blockDoms.push(div)
            //设置拼图块的位置
            this.setBlockItemPosition(div, index)
            fragment.appendChild(div)
        })
        return fragment
}

最后,我们给每个格子设置定位

// 设置
private async setBlockItemPosition(ele: HTMLElement, index: number) {
        const [x, y] = this.getCoordinateByIndex(index)
        ele.style.left = `${x * this.widthUnit}px`
        ele.style.top = `${y * this.heightUnit}px`
}

点击事件

我们首先添加点击事件,并判断是否可以移动

// 添加点击事件
private blockEvent = (e: Event) => {
        //如果拼图成功,则不再执行
        if (this.isSuccess()) {
            return
        }
        const target = e.target as HTMLElement
        const { onBlockMove } = this.options
        if (target.classList.contains('puzzle-grid-item')) {
            const curIndex = this.blockDoms.findIndex((item) => item === target)
            //检测当前块是否可以移动
            if (this.canMove(curIndex)) {
                this.steps++
                this.moveBlock(curIndex)
                onBlockMove?.(this.steps, target)
            }
        }
}
// 添加事件监听
private setBlockEvent() {
        this.gridDom?.removeEventListener('click', this.blockEvent) // 兼容初始化
        this.gridDom?.addEventListener('click', this.blockEvent, false)
}

移动格子-重点

这里其实并不复杂,只需要将当前格子与隐藏格子在数组中和位置上同时交换即可。

/**
  * 移动方块
  * @param index 当前点击块的索引
  * @param trigger 是否触发完成回调
  */
  public moveBlock(index: number, trigger = true): void {
        const targetDom = this.blockDoms[index] //格子dom数组,上面已赋值
        const hideIndex = this.getHideIndex() // 获取隐藏格子索引
        //交换一维DOM数组位置
        this.blockDoms[hideIndex] = targetDom
        this.blockDoms[index] = this.hideBlock!
        //交换style位置信息
        this.setBlockItemPosition(targetDom, hideIndex)
        this.setBlockItemPosition(this.hideBlock!, index)
        if (this.isSuccess() && trigger) {
            //执行成功回调
            this.handleSuccess()
        }
  }

随机打乱

我们按照10阶进行计算,确保复原步数在一个稳定的区间内,避免出现无解。

    // 随机打乱
    private async randomSetPosition() {
        const { col, row } = this.options
        const shuffleCount = col * row * 10
        let moveBlock: null | HTMLElement = null
        for (let i = 0; i < shuffleCount; i++) {
            //获取当前可移动的拼图块 并排除上次已经移动的块
            const canMoveList = this.blockDoms.filter(
                (block, index) => this.canMove(index) && block !== moveBlock
            )
            moveBlock = canMoveList[Math.floor(Math.random() * canMoveList.length)]
            const index = this.blockDoms.findIndex((item) => item === moveBlock)
            this.moveBlock(index, false)
        }
        //如果打乱后为成功状态 则继续打乱
        if (this.isSuccess()) {
            this.randomSetPosition()
        }
    }

拼图成功

怎么才算拼图成功呢?应该是打乱后的位置,经过左左右右左右移动以后等于初始位置才算成功,并且记录步数。

// 判断拼图是否成功
private isSuccess() {
        return this.blockDoms.every(
            (dom, index) =>
                this.getCoordinateByIndex(Number(dom.getAttribute('data-index'))).join(',') ===
                this.getCoordinateByIndex(index).join(',')
        )
}

成功就结束了吗?并没有!!我们还需要给回调函数返回steps和事件,以及显示8号隐藏格子

  //拼图结束
  private async handleSuccess() {
        const { success, transitionTime = 0.3 } = this.options
        //等待动画执行完毕
        await this.sleep(transitionTime * 1000) //延时动画
        this.hideBlock?.style.setProperty('display', 'block')
        this.hideBlock = null
        success?.(this.steps)
  }

初始化

最后我们设计一个初始化方法,做全局初始化。这里均是调用以上函数,注释已非非非常清晰了。记得在constructor里面执行一下。

private async initialize() {
        const { url, col, row, el, loadComplete } = this.options
        if (!el) {
            throw new Error('el must be a string or HTMLElement')
        }
        this.imageInfo = await this.loadImage(url)
        //获取网格坐标系的二维数组
        this.gridPostioin = this.getGridPosition(col, row)
        //获取拼图父容器
        this.gridContainer = typeof el === 'string' ? document.querySelector(el) : el
        //创建拼图容器
        this.gridDom = this.createGridConainer()
        //添加拼图子元素
        this.gridDom?.appendChild(this.createGridChildren())
        //将拼图容器添加到父容器中
        this.gridContainer?.appendChild(this.gridDom)
        //设置拼图容器事件
        this.setBlockEvent()
        //执行加载完毕回调事件
        loadComplete?.()
}

OK,结束,找个美女图跑一下效果。

自动寻路算法,在这里就不过多介绍了,感兴趣的可以关注我加群私聊。

总结

以上便是整个九宫格拼图游戏的实现过程。在这一节,你需要重点掌握一个解决问题的技巧实现的途径。

首先,你要养成对项目分析和构思的习惯,而不是通过CV大法被动式的接收解决问题的思路。并且能够通过构思推导出核心实现逻辑。

其次,实现过程中考虑业务所需,考虑代码性能拓展性易用性等。一步一步推导出整个实现过程。这样的实战经历才会对你的技术有很大帮助。否则,项目对你如同过眼烟云一般。

当然,如果你没有掌握好ES6+或TS,本节课可能对你有些吃力。不过你依旧可以通过回翻的方式,激进式学习。当你看不懂的地方时,回去翻阅你的资料,通过不停的来回翻阅达到快速掌握的能力。

恭喜你,忍住了枯燥,耐住了无聊,学完了本节课。
闲着没事的朋友可以我,点个赞评个论收个藏关个注。  手绘图,手打字,纯原创,摘自未发布的书籍:《高阶前端指北》,转载请获得本人同意。