乐趣区

关于javascript:汉诺塔小游戏开发教程

游戏简介

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

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

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

具体介绍及解法请参考文章:汉诺塔与递归。

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

舒适提醒:本篇教程属于从头到尾八面玲珑型,尽管开发上自身是没什么难度的,但不障碍把它做成一个很欠缺的游戏,所以它很长。

布局

本我的项目应用 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>

款式局部很简略就不列出来了,成果如下:

接下来是圆环,因为有三根柱子,所以应用三个数组来寄存,每个圆环用一个对象来示意,每个圆环有色彩、代表大小的序号属性,序号从 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>

成果如下所示:

拖动

这个游戏次要的交互就是拖动圆环到另一根柱子上,所以圆环须要反对拖动,须要留神的是每根柱子上都只有最下面的一个圆环能被拖动,且拖动到的柱子上存在的最下面的圆环还要比它大,否则不容许落下。

具体的实现就是监听鼠标按下事件、鼠标挪动事件、鼠标松开事件,鼠标按下挪动时扭转该圆环的 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>

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

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

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

翻译成代码如下:

{
    // 查看某个圆环的地位是否在某个柱子区域内
    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
    }
}

到这里游戏的外围性能就实现了,曾经能够玩了:

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

<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 就有过渡能力了,次要是合乎落下条件时从鼠标松开的地位过渡到指标地位须要计算一下,看图:

因为拖动中的圆环的 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()}

返回上一步

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

首先在之前的 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。

退出移动版