游戏简介

汉诺塔是源于印度一个古老传说的益智游戏,传说大梵天创造世界的时候顺便搞了三根柱子,一根柱子上摞着一堆从大到小的圆环,他命令婆罗门把圆环全部移动到另一个柱子上,依旧是从大到小,且移动规则如下:

1.一次只能把一个圆环从一根柱子移动到另一根柱子上

2.圆环的上面不能放比它大的圆环

游戏简介-LMLPHP

详细介绍及解法请参考文章:汉诺塔与递归

最终的成果示例请点击:汉诺塔小游戏

温馨提示:本篇教程属于从头到尾面面俱到型,虽然开发上本身是没什么难度的,但不妨碍把它做成一个很完善的游戏,所以它很长。

布局

本项目使用vue作为基础框架。

使用这些视图框架的主要思想就是操作数据,视图更新交给框架,只要做好数据和视图的映射即可,所以本游戏的核心也就是维护一些数据及操作数据。

首先要做的是布局,要模拟出上图中的三根柱子及圆环。本游戏全部使用DOM来布局,不使用canvas。

柱子的布局很简单,用div元素来作为线段,代码如下:

<template>
    <div class="container">
        <div class="(column, cIndex)" v-for="item in columnList" :key="item.name">
            <div class="col"></div>
            <div class="land"></div>
            <div class="name">{{item.name}}</div>
        </div>
    </div>
</template>

<script>
export default {
    name: 'Game',
    data() {
        return {
            columnList: [
                {
                    name: '起始柱'
                },
                {
                    name: '中转柱'
                },
                {
                    name: '终点柱'
                }
            ]
        }
    }
}
</script>

样式部分很简单就不列出来了,效果如下:

游戏简介-LMLPHP

接下来是圆环,因为有三根柱子,所以使用三个数组来存放,每个圆环用一个对象来表示,每个圆环有颜色、代表大小的序号属性,序号从1开始,1代表最大,因为圆环数量可变,所以每个圆环的宽高、位置都需要动态进行计算,渲染同样是循环进行渲染,三个圆环的情况如下所示:

<template>
<div class="container">
    <div class="column" v-for="(item, cIndex) in columnList" :key="item.name">
        <!--省略...-->
        <div class="ringsBox">
            <div 
                class="ring" 
                v-for="(ringItem, index) in ringList[item.prop]" 
                :key="ringItem.order" 
                :style="{
                    width: (wsize - (ringItem.order - 1) * 10) + '%',
                    height: hsize / ringNum + '%',
                    backgroundColor: ringItem.color,
                    left: (100 - (wsize - (ringItem.order - 1) * 10)) / 2 + '%',
                    bottom: (hsize / ringNum) * index + '%'
                }"
            ></div>
        </div>
    </div>
</div>
</template>

<script>
export default {
    name: "Game",
    data() {
        return {
            // 柱子
            // 增加了一个prop属性,代表该柱子对应的圆环数组
            columnList: [
                {
                    name: "起始柱",
                    prop: "startColRingList",
                },
                {
                    name: "中转柱",
                    prop: "transferColRingList",
                },
                {
                    name: "终点柱",
                    prop: "endColRingList",
                },
            ],
            // 圆环
            // 圆环数量
            ringNum: 3,
            // 圆环数据
            ringList: {
                startColRingList: [
                    {
                        color: "#ffa36c",
                        order: 1,
                    },
                    {
                        color: "#00bcd4",
                        order: 2,
                    },
                    {
                        color: "#848ccf",
                        order: 3,
                    }
                ],
                transferColRingList: [],
                endColRingList: [],
            },
        };
    },
    computed: {
        // 最大宽度值
        wsize() {
            return this.ringNum <= 5 ? 50 :  this.ringNum * 10
        },
        // 最大高度值
        hsize() {
            return this.ringNum <= 3 ? 30 :  this.ringNum * 10
        }
    }
};
</script>

效果如下所示:

游戏简介-LMLPHP

拖动

这个游戏主要的交互就是拖动圆环到另一根柱子上,所以圆环需要支持拖动,需要注意的是每根柱子上都只有最上面的一个圆环能被拖动,且拖动到的柱子上存在的最上面的圆环还要比它大,否则不允许落下。

具体的实现就是监听鼠标按下事件、鼠标移动事件、鼠标松开事件,鼠标按下移动时改变该圆环的transform: translate(x,y)属性来进行移动,鼠标松开时判断当前圆环被拖动到的位置是否在三个圆环的某一个区域内,是的话再判断圆环能否落到该柱子上,符合条件就把该圆环的数据从之前柱子的数组移到落下柱子的数组内,否则就复位transform属性让圆环回去。

绑定事件需要注意的是按下事件绑定到圆环上,而移动和松开事件要绑定到body上,否则当你移动过快时鼠标指针可能会和圆环不同步而超出圆环,进而当你松开后就监听不到松开事件了。

<template>
<div class="container">
    <div class="column" v-for="(column, cIndex) in columnList" :key="item.name">
        <!--省略...-->
        <div class="ringsBox">
            <div 
                class="ring" 
                v-for="(ringItem, index) in ringList[item.prop]" 
                <!--省略...-->
                @mousedown="mousedown($event, ringItem, index, item.prop, cIndex)"
            ></div>
        </div>
    </div>
</div>
</template>

<script>
export default {
    name: "Game",
    // ...
    mounted() {
        this.bindEvent()
    },
    beforeDestroy() {
        this.unbindEvent()
    },
    methods: {
        // 鼠标移动事件和松开事件绑定到body上
        bindEvent() {
            document.body.addEventListener('mousemove', this.mousemove)
            document.body.addEventListener('mouseup', this.mouseup)
        },

        // 解绑事件
        unbindEvent() {
            document.body.removeEventListener('mousemove', this.mousemove)
            document.body.removeEventListener('mouseup', this.mouseup)
        }
    }
};
</script>

接下来重点实现这三个事件处理函数。

先定义一些必要的变量:

{
    // 拖动变量
    dragProp: '',// 当前拖动圆环所属的柱子
    dragOrder: 0,// 当前拖动圆环的大小序号
    dragIndex: -1,// 当前拖动圆环在原柱子上的索引
    dragColumnIndex: -1,// 当前拖动圆环所在柱子的索引
    draging: false,// 当前是否是拖动中
    startPos: {// 鼠标按下时的坐标
        x: 0,
        y: 0
    },
    dragPos: {// 鼠标移动的偏移量
        x: 0,
        y: 0
    }
}

拖动是拖动当前鼠标按下的圆环,因为是在循环体里添加的css属性,所以对所有圆环都是有效的,那么怎么判断目标圆环是哪个圆环,对于圆环来说,它的order属性是唯一的,所以根据dragOrder变量就可以定位到了,是的话就让它的translate的值随着dragPos的值进行变化:

<template>
<div class="container">
    <div class="column" v-for="(column, cIndex) in columnList" :key="item.name">
        <!--省略...-->
        <div class="ringsBox">
            <div 
                class="ring" 
                v-for="(ringItem, index) in ringList[item.prop]" 
                :key="ringItem.order" 
                :style="{
                    <!--省略...-->
                    transform: dragOrder === ringItem.order ? `translate(${dragPos.x}px, ${dragPos.y}px)` : 'translate(0px, 0px)'
                }"
            ></div>
        </div>
    </div>
</div>
</template>

鼠标按下事件处理函数的主要逻辑是设置拖动标志位、缓存当前拖动的一些数据,比如当前拖动圆环的相关信息及鼠标按下的位置信息:

{
    // 鼠标按下事件
    mousedown(e, ringItem, index, prop, columnIndex) {
        // 当按下的不是该柱子最上面的圆环时不做任何处理
        if (index < this.ringList[prop].length - 1) {
            return
        }
        this.dragProp = prop
        this.dragOrder = ringItem.order
        this.dragIndex = index
        this.dragColumnIndex = columnIndex
        this.startPos.x = e.clientX
        this.startPos.y = e.clientY
        this.draging = true
    }
}

鼠标移动事件处理函数的功能是实时更新拖动的偏移量,圆环就会跟着动了:

{
    // 鼠标移动事件
    mousemove(e) {
        // 不是拖动的情况直接返回
        if (!this.draging) {
            return
        }
        this.dragPos.x = e.clientX - this.startPos.x
        this.dragPos.y = e.clientY - this.startPos.y
    }
}

鼠标松开事件是最重要的,在该函数里需要判断圆环是否拖动到某个柱子区域内及能否落下及具体的落下操作:

{
    // 鼠标松开事件
    mouseup() {
        // 不是拖动的情况直接返回
        if (!this.draging) {
            return
        }
        // 复位拖动标志位
        this.draging = false
        // 计算圆环拖动到哪个柱子上
        let columnIndex = this.checkInColumnIndex(this.dragOrder)
        // 判断圆环是否可以落到该柱子上
        let canDraged = this.canDraged(columnIndex, this.dragOrder)
        // 能落下的话就移动该圆环的数据
        if (canDraged) {
            this.dragToColumn(columnIndex, this.dragProp, this.dragIndex)
        }
        // 复位
        this.reset()
    }
}

接下来一步步来实现该函数里的几个方法。

因为涉及到位置计算,所以需要获取实际的DOM元素,先在模板里加上ref用于引用DOM:

<template>
<div class="container">
    <div class="column" v-for="(item, cIndex) in columnList" :key="item.name" :ref="'column' + cIndex">
        <div class="ringsBox">
            <div 
                class="ring" 
                v-for="(ringItem, index) in ringList[item.prop]" 
                :ref="'ring' + ringItem.order"
            ></div>
        </div>
    </div>
</div>
</template>

首先柱子区域是一个矩形,如下所示:

游戏简介-LMLPHP

然后圆环其实也是一个矩形,那么问题实际上就转换为求两个矩形是否相交,这个是很简单的,方便起见,把它们的位置都相对于浏览器窗口左上角来计算,那么满足下面的条件圆环和柱子区域即相交:

1.圆环的右侧距窗口左侧的距离大于柱子区域左侧距窗口左侧的距离、同时圆环左侧距窗口的距离小于柱子区域右侧距窗口左侧的距离
2.圆环的顶部距窗口顶部的距离小于柱子区域的底部距窗口顶部的距离、同时圆环的底部距窗口顶部的距离大于柱子区域顶部距窗口顶部的距离

游戏简介-LMLPHP

翻译成代码如下:

{
    // 检查某个圆环的位置是否在某个柱子区域内
    checkInColumnIndex(order) {
        let result = -1
        // 获取圆环相当于浏览器窗口的位置信息
        let ringRect = this.$refs['ring' + order][0].getBoundingClientRect()
        // 遍历获取柱子区域相当于浏览器窗口的位置信息
        ;[0, 1, 2].forEach((index) => {
            // 获取区域位置信息
            let {left, right, top, bottom} = this.$refs['column' + index][0].getBoundingClientRect()
            // 重合检查
            if (
                (ringRect.right >= left && ringRect.left <= right) && (ringRect.top <= bottom && ringRect.bottom >= top)) 
            {
                result = index
            }
        })
        return result
    }
}

知道了在哪个圆环后接下来要判断是否可以落下,根据游戏规则,小的圆环上不能放大的,所以判断当前柱子上最小的圆环是否比当前圆环大即可:

{
    // 判断某个圆环是否可以落到指定索引的柱子上
    canDraged(columnIndex, order) {
        // 不在圆环区域内直接返回
        if (columnIndex === -1) {
            return 
        }
        let prop = this.columnList[columnIndex].prop
        let list = this.ringList[prop]
        // 柱子为空则可以落下
        if (list.length <= 0) {
            return true
        }
        // 数组里最后一项即是当前柱子最小的圆环
        let minOrder = list[list.length - 1].order
        if (order > minOrder) {
            return true
        }
        return false
    }
}

判断如果是可以落下的那么直接将该圆环的数组从原柱子数组移到目标数组即可:

{
    // 某个圆环落到指定索引的柱子上
    dragToColumn(columnIndex, prop, index) {
        // 从原数组取出
        let ring = this.ringList[prop].splice(index, 1)[0]
        // 追加到目标数组
        let toProp = this.columnList[columnIndex].prop
        this.ringList[toProp].push(ring)
    }
}

如果不能落下的话那么就让圆环回去,圆环的位置要回去的话直接把dragPos的值恢复要0即可,其他的相关变量也需要复位:

{
    // 拖动完成后复位
    reset() {
        this.dragProp = ''
        this.dragOrder = 0
        this.dragIndex = null
        this.draging = false
        this.dragColumnIndex = -1
        this.startPos.x = 0
        this.startPos.x = 0
        this.dragPos.x = 0
        this.dragPos.y = 0
    }
}

到这里游戏的核心功能就完成了,已经可以玩了:

游戏简介-LMLPHP

图上的圆环移到某个区域内显示的背景突出效果实现也很简单,在移动过程中不断检测是否相交,是的话就给对应的区域加上背景的类名:

<template>
<div class="container">
    <div class="column" v-for="(item, cIndex) in columnList" :key="item.name" :ref="'column' + cIndex" :class="{dragIn: dragingColumnIndex === cIndex}">
        
    </div>
</div>
</template>

{
    data() {
        return {
            dragingColumnIndex: -1//拖动过程中实时相交的区域索引
        }
    },
    methods: {
        mousemove(e) {
            //...
            this.dragingColumnIndex = this.checkInColumnIndex(this.dragOrder)
        }
    }
}

完成检测

每一次拖动后都要判断游戏是否完成,判断方式很简单,检测目标数组不为空,而其他两根柱子的数组为空就可以了,或者直接检测目标数组里的圆环数量是否和当前层数对应,反正方式有很多。

{
    // 检测游戏是否完成
    checkPass() {
        if (this.ringList.endColRingList.length === this.ringNum) {
            alert('恭喜你,完成啦')
        }
    }
}

就是这么简单。

游戏基本功能到这里就结束了,但是作为一个有梦想有追求的人,完成基本功能只意味着开始,随便想想,就能想到还有很多能做的:游戏层数选择、操作按钮、信息显示,还有一些高级功能:回退操作、自动操作、步骤回放等等,因为篇幅原因,本篇不会全部展开讲解,只挑一两个来浅析一下,不要走开,精彩继续。

动画过度

首先先做个优化,目前来说,当你拖动圆环到某个柱子上松开时圆环是瞬间显示到柱子上的,而不是过渡过去的,包括当松开鼠标不符合落下条件圆环回去也是一样,突变总是不优雅的,我们让它平滑的滑动起来。

因为圆环是使用css的translate属性来跟随鼠标动的,所以只要给它加上transition属性即可平滑过渡,要注意的是拖动过程中该属性的值必须为none,否则你每拖动一下,它都要缓一下过渡过去,所以该属性的值要动态进行设置。

圆环不符合落下条件时复位的过渡不需要修改,加上transition就有过渡能力了,主要是符合落下条件时从鼠标松开的位置过渡到目标位置需要计算一下,看图:

游戏简介-LMLPHP

因为拖动中的圆环的transition的坐标也就是dragPos属性的值是相当于鼠标按下的位置来说的,其实也就是圆环开始的位置,所以只要知道圆环即将落到的目标位置相对于圆环开始的位置,把该坐标设置给dragPos就可以了,css动画方式就是如此的简单明了:

<template>
<div class="container">
    <div class="column" v-for="(item, cIndex) in columnList">
        <div class="ringsBox">
            <div 
                class="ring" 
                v-for="(ringItem, index) in ringList[item.prop]" 
                :style="{
                    <!--省略...-->
                    transition: transition
                }"
            ></div>
        </div>
    </div>
</div>
</template>

{
    data() {
        return {
            transition: 'none'
        }
    },
    methods: {
        mousedown(e, ringItem, index, prop, columnIndex) {
            // ...
            // 鼠标按下时说明可能要进行拖动,那么该属性要设为null
            this.transition = 'none'
            // ...
        },
        // 重点改造鼠标松开事件函数
        async mouseup() {
            if (!this.draging) {
                return
            }
            this.draging = false
            let columnIndex = this.checkInColumnIndex(this.dragOrder)
            let canDraged = this.canDraged(columnIndex, this.dragOrder)
            // 设置过渡效果
            this.transition = 'all 0.5s'
            if (canDraged) {
                // 核心函数,让圆环从松开的位置移动到目标位置,因为过渡需要时间,所以使用await进行等待
                await this.moveToNewPos(columnIndex, this.dragProp, this.dragIndex)
                // 圆环物理位置过去以后,实际该圆环的数据还是在原来的柱子数组里的,所以还是需要把它移到目标数组
                this.dragToColumn(columnIndex, this.dragProp, this.dragIndex)
                // 过渡完以后删掉过渡效果
                this.transition = 'none'
                // 复位数据
                this.reset()
                this.checkPass()
            } else {
                this.reset()
            }
        }
    }
}

接下来就是要实现上面的移动函数moveToNewPos,其实就是计算目标位置的坐标,该坐标是相当于圆环起始坐标来说的,方便计算也先它们都转化为相当于浏览器窗口,然后相减就得到了最终结果:

{
   moveToNewPos(columnIndex, prop, index) {
        // 因为过渡需要500毫秒,所以使用promise
        return new Promise((resolve, rejct) => {
            let ring = this.ringList[prop][index]
            // 将圆环起始坐标转化为距浏览器窗口坐标
            let startPos = this.getRingPosOffsetWindow(this.dragColumnIndex, ring.order, true)
            // 将圆环目标坐标转化为距浏览器窗口坐标
            let endPos = this.getRingPosOffsetWindow(columnIndex, ring.order)
            // 相减得到目标坐标相当于起始坐标的值
            this.dragPos.x = endPos.left - startPos.left
            this.dragPos.y = endPos.top - startPos.top
            // 让圆环过渡完
            setTimeout(() => {
                resolve()
            }, 500);
        })
    } 
}

getRingPosOffsetWindow方法是计算某个柱子上指定索引的圆环的位置相当于浏览器窗口的距离,第三个参数为true代表该圆环是否已经存在于该柱子,为false代表是即将落下的目标位置:

{
    getRingPosOffsetWindow(columnIndex, order, exist) {
        // 该柱子的圆环数组
        let prop = this.columnList[columnIndex].prop
        // 该柱子区域的尺寸位置信息
        let rect = this.$refs['column' + columnIndex][0].getBoundingClientRect()
        // 圆环在该柱子上的索引
        let index = this.ringList[prop].length - (exist ? 1 : 0)
        // 圆环相当于柱子区域的位置信息
        let left = (100 - (this.wsize - (order - 1) * 10)) / 2 + '%'
        let bottom = (this.hsize / this.ringNum) * index + '%'
        let height = this.hsize / this.ringNum + '%'
        // 转换为像素
        let leftPx = rect.width * parseFloat(left) / 100
        // 底部线段占了5像素
        let _height = rect.height - 5
        let topPx = _height - (_height * parseFloat(bottom) / 100) - (parseFloat(height) * _height / 100)
        // 转换为屏幕上的坐标
        let windowLeftPx = rect.left + leftPx
        let windowTopPx = rect.top + topPx
        return {
            left: windowLeftPx, 
            top: windowTopPx
        }
    }
}

到这里松开圆环圆环就会过渡到目标位置,

最少步数与自动操作

汉诺塔游戏可以用递归来求解,详细了解可参考文章开头提到的文章,此处不再赘述,直接贴出递归函数:

export default {
    data() {
        return {
            minStepNum: 0//当前层数最少步数
        }  
    },
    methods: {
        // 计算指定层数的解法吉最少步数
        resolveHannuota(num, start, transfer, end) {
            if (num <= 0) {
                return;
            }
            this.resolveHannuota(num - 1, start, end, transfer)
            console.log(start + '->' + end)
            this.minStepNum++
            this.resolveHannuota(num - 1, transfer, start, end)
        }
    }
}

层数改变很简单,把之前写死的startColRingList数组改成遍历生成就可以了,每次层数改变后都调一下上面的resolveHannuota方法,minStepNum累加的结果就是最少次数,console.log打印的就是步骤,三层打印的结果如下所示:

startColRingList->endColRingList
startColRingList->transferColRingList
endColRingList->transferColRingList
startColRingList->endColRingList
transferColRingList->startColRingList
transferColRingList->endColRingList
startColRingList->endColRingList

可以通过解析该数据来实现自动操作。

// 柱子索引
const propIndex = {
    startColRingList: 0,
    transferColRingList: 1,
    endColRingList: 2,
}
// 自动操作
function auto() {
    let index = 0
    let loop = async () => {
        // autoStepList数组就是上面console打印的内容
        if (index > this.autoStepList.length - 1) {
            return;
        }
        let cur = this.autoStepList[index]
        let columnIndex = propIndex[cur.to]
        this.dragColumnIndex = propIndex[cur.from]
        let dragIndex = this.ringList[cur.from].length - 1
        this.transition = "all 0.5s";
        this.dragOrder = this.ringList[cur.from][dragIndex].order
        // 调用之前过渡的方法
        await this.moveToNewPos(columnIndex, cur.from, dragIndex);
        // 移动数组元素
        this.dragToColumn(columnIndex, cur.from, dragIndex);
        this.transition = "none";
        this.dragPos.x = 0
        this.dragPos.y = 0
        index++
        setTimeout(() => {
            loop()
        }, 500);
    }
    loop()
}

游戏简介-LMLPHP

返回上一步

返回上一步也很简单,通过数组记录下每一步,然后每点一次就把数组最后一项弹出来,通过上述动画方式移动对应的圆环即可。

首先在之前的mouseup函数里保存每一步的操作:

{
    // 鼠标松开事件函数
    async mouseup() {
        // ...
        this.transition = 'all 0.5s'
        if (canDraged) {
            await this.moveToNewPos(columnIndex, this.dragProp, this.dragIndex)
            
            // 在这里把这一步的操作添加到数组里,注意回退操作是把这一步的目标位置回到开始位置
            this.historyList.push({
                to: this.dragProp,
                from: this.columnList[columnIndex].prop
            })
            
            // ...
        } else {
            this.reset()
        }
    }
}

然后点点击回退按钮时弹出最后一步进行回退:

{
    // 返回上一步
    async goback() {
        if (this.historyList.length <= 0) {
            return
        }
        let cur = this.historyList.pop()
        let columnIndex = propIndex[cur.to]
        this.dragColumnIndex = propIndex[cur.from]
        let dragIndex = this.ringList[cur.from].length - 1
        this.transition = "all 0.5s";
        this.dragOrder = this.ringList[cur.from][dragIndex].order
        await this.moveToNewPos(columnIndex, cur.from, dragIndex);
        this.dragToColumn(columnIndex, cur.from, dragIndex);
        this.transition = "none";
        this.dragPos.x = 0
        this.dragPos.y = 0
    }
}

至此,游戏的全部功能都已完成,源代码已经上传到github:https://github.com/wanglin2/hannuota

08-03 17:30