布局设计
在开始我们的设计之前先回忆一下华容道是个什么游戏。
从游戏性的角度来说,我们是把方块拖到空位来不停移动,最后还原回正确的位置。但是从程序的角度说,其实并不需要真的拖动方块,我们只需要删除这个方块再放到空位里去就行了。因为我们点击一个方块只有两种结果:
- 1.方块周围没有空格,没有任何变化;
- 2.方块周围有空格,那么方块和空格交换位置。
事实上很多版本的华容道也就是这样做的。不过我今天做的华容道希望尽可能能还原真实的华容道操作。因此我选择的方案是可以让方块自由的拖动。
所以这些方块被我设计成同级的兄弟元素,外面一整个大容器包裹住。考虑到可以自由移动,首先我们先让每个元素都绝对定位。之后再让每个方块元素找到自己对应的位置。
这里有两种方法将元素定位到正确的位置,一是使用translate移动。二是使用top和left直接定位。我选择的是方案二,因为这样利用vue的响应式能够比较方便的维护状态。使用translate的好处是可以利用gpu加速,渲染会更快。当然我实际体验下来利用top和left来定位已经非常流畅了。因此后续就继续采用第二种方案了。
计算布局
我希望这个组件被封装好以后可以放在我想要的任何地方,因此需要动态的生成宽高。不知道大家有没有做过这种需求,要求一个元素在保持宽高比的情况下尽可能的占据屏幕。一个典型场景就是vedio全屏播放,在竖屏和横屏下的都尽可能的撑大了。
css有一个单位叫做vmin,这个代表屏幕宽高相对较小的那个值。不过仅仅支持全屏依然是有点可惜。所以我们这里就动态计算下宽高就好了。再利用vue3的新特性,v-bind绑定css来解决。
在scss中,不能直接绑定,而是要通过绑定一个变量来曲线救国:
script:
typescript
const huarong = ref<VueElement | null>(null)
const dimension = ref(props.dimension)
const vmin = computed(() => {
if (huarong.value == null) {
return 0
}
let temp = min(huarong.value?.clientHeight, huarong.value?.clientWidth)
temp = temp - (temp % dimension.value)
return temp
})
const length = computed(() => {
return `${vmin.value}px`
})
const itemLength = computed(() => {
return vmin.value / dimension.value
})
const itemLengthStr = computed(() => {
return `${itemLength.value}px`
})
template:
html
<template>
<div ref="huarong" class="huarong-box">
<div class="huarong-content">
<div
v-for="item in piecesPosition"
:key="item.serial"
:class="`huarong-item_${item.serial} ${item.status}`"
:style="item.position" >
</div>
</div>
</div>
</template>
style:
scss
$length: v-bind(length);
.huarong-box {
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
.huarong-content {
height: $length;
width: $length;
$item-length: v-bind(itemLengthStr);
position: relative;
div[class^="huarong-item"] {
padding: 5px;
display: inline-block;
width: $item-length;
height: $item-length;
background-color: skyblue;
position: absolute;
border-radius: 8px;
border: 2px solid $backgroundColor;
}
}
}
生成postion数组
平平无奇的计算下位置:
typescript
interface PiecePosition {
serial: number
status: "piece" | "space"
position: {
top: string
left: string
}
}
function getPiecesPosition(length: number, dimension: number): PiecePosition[] {
let top = 0
let left = 0
const step = length / dimension
const len = dimension ** 2
const arr = new Array(len)
for (let i = 0; i < dimension; i++) {
for (let j = 0; j < dimension; j++) {
const serial = i * dimension + j
arr[serial] = {
serial,
status: "piece",
position: {
top: `${top}px`,
left: `${left}px`,
},
}
left += step
}
top += step
left = 0
}
arr[arr.length - 1].status = "space"
return arr
}
控制拖拽
一个一个的给碎片添加eventlistener当然不是一个好的决定,无须多言,采用事件委托,在父级元素上绑定事件,这里采用pointerEvent,人要用梦想,万一以后移植app端了呢?
事件委托
template:
html
<div class="huarong-content" @pointerdown="downFunc($event)" @pointerup="upFunc($event)">
<div
v-for="item in piecesPosition"
:key="item.serial"
:class="`huarong-item_${item.serial} ${item.status}`"
:style="item.position">
{{ item.serial }}
</div>
</div>
script:
typescript
const downFunc = (val: PointerEvent) => {
if (val.button !== 0) {
return
}
const startX = val.x
const startY = val.y
const target = parseInt((val?.target as HTMLElement).className.split(" ")[0].split("_")[1])
if (target == null) {
return
}
moveTarget.value = target
const top = parseInt(piecesPosition.value[target].position.top.split("px")[0])
const left = parseInt(piecesPosition.value[target].position.left.split("px")[0])
let [lastTimeTop, lastTimeLeft] = [top, left]
moveFunc = (event: Event) => {
const mouseEvent = event as PointerEvent
if (mouseEvent.buttons !== 1) {
upFunc(mouseEvent)
}
const dx = mouseEvent.x - startX
const dy = mouseEvent.y - startY
const [x, y] = getLimitTrans(left + dx, top + dy, lastTimeLeft, lastTimeTop)
lastTimeLeft = x
lastTimeTop = y
piecesPosition.value[target].position.top = `${y}px`
piecesPosition.value[target].position.left = `${x}px`
}
window.addEventListener("pointermove", moveFunc)
}
const upFunc = (val: PointerEvent) => {
window.removeEventListener("pointermove", moveFunc)
const moveDiv = document.getElementsByClassName(`huarong-item_${moveTarget.value}`)[0] as HTMLElement
const topVal = parseInt(moveDiv.style.top.split("px")[0])
const leftVal = parseInt(moveDiv.style.left.split("px")[0])
const setPosition = () => {
piecesPosition.value[moveTarget.value].position.top = `${getPiecePa(topVal)}px`
piecesPosition.value[moveTarget.value].position.left = `${getPiecePa(leftVal)}px`
moveTarget.value = -dimension.value
}
nextTick(() => {
setPosition()
})
}
这里对setPositon()做了一些特殊处理,也就是在移动结束之后处理最终方块的位置。后文优化中会详细讲解这一点。
拖出窗口外怎么办?
有做过拖拽功能的朋友应该都为鼠标拖出窗口感到头疼,
这里我们利用pointerEvent里的buttons参数来控制监听事件的销毁。buttons在MDN上的解释如下:
一个数字,用来标识鼠标按下的一个或者多个按键。如果按下的键为多个,则值等于所有按键对应数值进行或 (|) 运算的结果。
0
: 没有按键或者是没有初始化1
: 鼠标左键2
: 鼠标右键4
: 鼠标滚轮或者是中键8
: 第四按键 (通常是"浏览器后退"按键)16
: 第五按键 (通常是"浏览器前进")
如果松开鼠标左键,那么我们就直接触发upFunc。
typescript
const downFunc = (val: PointerEvent) => {
// ...
moveFunc = (event: Event) => {
const mouseEvent = event as PointerEvent
if (mouseEvent.buttons !== 1) {
upFunc(mouseEvent)
}
// ...
}
}
碰撞算法
完成拖拽移动的一大问题是,如何控制方块只能在正确的区间内移动?
头脑风暴了很久,想到的方案就是遍历所有兄弟元素的position,如果发现要碰撞了,就禁止移动。具体是实现上就是设置-1表示不需要在这个方向上移动,代码如下:
typescript
const getLimitTrans = (left: number, top: number) => {
let pos = [-1, -1]
pos = [left, top]
// 计算不和兄弟元素重合
const positions = piecesPosition.value
for (let i = 0; i < positions.length - 1; i++) {
if (i === moveTarget.value) {
continue
}
const broLeft = parseFloat(positions[i].left.split("px")[0])
const broTop = parseFloat(positions[i].top.split("px")[0])
if (
broLeft - itemLength.value < left &&
left < broLeft + itemLength.value &&
broTop - itemLength.value < top &&
top < broTop + itemLength.value
) {
if (broLeft - itemLength.value < left && left < broLeft + itemLength.value) {
pos[0] = -1
}
if (broTop - itemLength.value < top && top < broTop + itemLength.value) {
pos[1] = -1
}
}
}
// 超出box边界
if (left < 0 || left > vmin.value - itemLength.value) {
pos[0] = -1
}
if (top < 0 || top > vmin.value - itemLength.value) {
pos[1] = -1
}
return pos
}
反人类拖拽?
前面的设计有一个缺陷,那就是假设我们斜着移动,也就是移动方块的力在x,y两个方向分力均不为0,那就无法正常移动。而真实世界的华容道,发生碰撞方向的力会被抵消,方块仍然会沿着没有发生碰撞的方向移动。
我解决这个问题的大致想法是这样:假设在x方向上没有位移,依旧发生碰撞,说明y方向是导致碰撞的原因之一,此时我们将y方向上的位移取消。反之y方向上如果没有位移,依旧发生碰撞,则取消x方向的位移。
具体实现上,我们传入当前时刻的经过计算后应该移动的位置,和上一刻移动的位置,如果发现某个方向有碰撞,就用上一刻对应位置代替,这样就保持了静止。
经过优化后,代码如下:
typescript
const getLimitTrans = (left: number, top: number, lastTimeLeft: number, lastTimeTop: number) => {
// 计算不和兄弟元素重合
const positions = piecesPosition.value
for (let i = 0; i < positions.length; i++) {
if (i === moveTarget.value || i === spaceTarget.value) {
continue
}
const broLeft = parseFloat(positions[i].position.left.split("px")[0])
const broTop = parseFloat(positions[i].position.top.split("px")[0])
const isCollision = (tempLeft: number, tempTop: number) => {
// 留出一点冗余,避免css和js精度丢失导致的问题
const xLeftLimit = broLeft - itemLength.value + 1
const xRightLimit = broLeft + itemLength.value - 1
const yLeftLimit = broTop - itemLength.value + 1
const yRightLimit = broTop + itemLength.value - 1
return xLeftLimit < tempLeft && tempLeft < xRightLimit && yLeftLimit < tempTop && tempTop < yRightLimit
}
// 如果取消x方向的移动后,依然发生碰撞,说明需要取消y方向,反之亦然。
if (isCollision(left, lastTimeTop)) {
left = lastTimeLeft
}
if (isCollision(lastTimeLeft, top)) {
top = lastTimeTop
}
}
// 超出box边界
if (left < 0 || left > vmin.value - itemLength.value) {
left = lastTimeLeft
}
if (top < 0 || top > vmin.value - itemLength.value) {
top = lastTimeTop
}
return [left, top]
}
随机算法
如何打乱华容道的方块?
一开始想到的是如何随机生成0-dimension ** 2,简单了解了下shuffle算法。
shuffle算法
简单的来说就是先按顺序生成一个递增序列,然后每次随机生成一个0-dimension ** 2的数,用这个随机数和数组的最后一位交换,这样就生成了随机序列,数学上已经证明这个算法是均匀的了,因此我们直接拿来主义,简单写写:
typescript
type Exchange<T> = (item1: T, item2: T) => void
function shuffle<T>(arr: T[], exchange?: Exchange<T>): T[] {
const tempArr = JSON.parse(JSON.stringify(arr))
const len = tempArr.length
for (let i = 0; i < len; i++) {
const randomIndex = Math.floor(Math.random() * len)
if (exchange) {
exchange(tempArr[randomIndex], tempArr[len - 1])
} else {
;[tempArr[randomIndex], tempArr[len - 1]] = [tempArr[len - 1], tempArr[randomIndex]]
}
}
return tempArr
}
这里拓展了一下shuffle算法的能力,可以通过传入一个exchange函数交换指定的内容。
写完之后高高兴兴运行了一下,前两次还好,轻轻松松就还原了华容道。然而第三次的时候出现问题了,最后剩下两个方块位置反了,死活还原不回去。
在数学上很简单可以证明,如果是奇数个相邻的两个方块位置的位置反了,那么无论如何是无法通过移动还原回去的,我有一个绝妙的证明,可惜掘金的服务器写不下了,大家有兴趣可以自己证明一下。
发生这个问题瞬间我就意识到随机算法是有问题的,我们不能通过简单地shuffle算法解决这个问题,否则有一定概率出现华容道无法还原的脑淤血现象。
了解了华容道背后的数学原理,我们只需要动动小脑筋就可以写出升级后的shuffle算法,保证华容道可以被还原,刚刚我们已经说了,如果最终有奇数个逆序对就有问题。反之偶数对就是可以还原的,因此我们简单优化一下......才怪。
升级随机算法
实际上我并没有通过这种方式来完成随机算法,主要是觉得没必要,肯定不是我写不出来。最终我采用的是一个笨办法,小日子过得不错的人有句话说得好,逃避虽然可耻但很有用。既然无法找到一个靠谱的数学办法解决问题,我们就直接模拟真实操作。
我们将空格周围随机一个方向的方块移入空格,只要移动的次数够多,就可以保证最终的顺序足够随机,并且是肯定可以还原回去的。
说干就干:
javascript
const piecesShuffle = (pieces: PiecePosition[]) => {
const tempPieces: PiecePosition[] = JSON.parse(JSON.stringify(pieces))
let spaceIndex = tempPieces.length - 1
// 模拟真实操作以防止出现无法还原的情况
const shufflePosition = () => {
const direction = Math.floor(Math.random() * 4)
let moveIndex = spaceIndex
switch (direction) {
case 0:
// 上
if (Math.floor(spaceIndex / dimension.value) !== 0) {
moveIndex = spaceIndex - dimension.value
}
break
case 1:
// 下
if (Math.floor(spaceIndex / dimension.value) !== dimension.value - 1) {
moveIndex = spaceIndex + dimension.value
}
break
case 2:
// 左
if (spaceIndex % dimension.value !== 0) {
moveIndex = spaceIndex - 1
}
break
case 3:
// 右
if (spaceIndex % dimension.value !== dimension.value - 1) {
moveIndex = spaceIndex + 1
}
break
}
// 交换空位与碎片
const tempPiece = JSON.parse(JSON.stringify(tempPieces[moveIndex]))
tempPieces[moveIndex] = {
...tempPieces[spaceIndex],
serial: tempPiece.serial,
}
tempPieces[spaceIndex] = {
...tempPiece,
serial: tempPieces[spaceIndex].serial,
}
spaceIndex = moveIndex
}
// 随机步数
let i = (dimension.value ** dimension.value) ** 2
while (i > 0) {
shufflePosition()
i--
}
return tempPieces
}
这里我采用的随机步长是总片数的平方。 需要注意边界条件,因为在第一行我们是无法将上方的空格移入的,同理,最后一行,第一列,最后一列都有各自的边界条件。
验证结果
最后一步就是验证用户是否通关了。办法也比较简单,由于我们只改变了每个方块的位置,而没有改变顺序,因此只需要保存初始化时候的位置,然后调用every方法验证下是不是每个都归位了即可。
typescript
const initPosition = ref<PiecePosition[]>([])
const checkResult = () => {
return piecesPosition.value.every((item, index) => {
if (item.status === "space") {
return true
}
return item.position.top === initPosition.value[index].position.top && item.position.left === initPosition.value[index].position.left
})
}
const upFunc = (val: PointerEvent) => {
// ...
nextTick(() => {
setPosition()
if (checkResult()) {
pass()
}
})
}
优化
到这里这个华容道小游戏已经可以正常运行了,后面涉及到一些优化问题,用到了go,毕竟这其实是我写的一个wails项目,不过其实这些问题也不只是语言层面的,纯前端的开发者也可以继续阅读。
回顾之前的代码,最终运行的时候其实还是有一点小问题。
随机算法阻塞
首先就是随机算法在华容道的维度上去以后,消耗的时间毕竟是指数级增加的,这会极大程度的阻塞页js执行,我自己试了一下,大概在8*8的时候就会卡死。当然,我们也可以优化随机算法或者减少随机的步数。但是8*8的华容道怎么说呢,某种意义上感觉不太适合打发时间了,也就没有改进随机算法的动力了。
不过还是先做一点简单的小优化。相信有的朋友看到代码的时候就想到了,实际上我们没必要每次都真的交换position,只需要生成一个数组,然后按照算法打乱数组,等随机算法执行完毕再找到对应index的方块交换位置即可。
之后我们来解决阻塞的问题,最初想的是用worker线程或者wasm来计算,js等待执行结果就可以了。不过反正我是个wails项目,直接用go封装下,让js调接口完事。综合上述优化后的代码如下:
go的随机算法部分:
go
// 随机算法
func GenerateRandomPieces(dimension int, loop int) []int {
a := makeRange(0, int(math.Pow(float64(dimension), 2.0))-1)
rand.New(rand.NewSource(time.Now().UnixNano()))
spacePieceIndex := len(a) - 1
for loop > 0 {
direction := rand.Intn(4)
moveIndex := spacePieceIndex
switch direction {
case 0:
if spacePieceIndex/dimension != 0 {
moveIndex = spacePieceIndex - dimension
}
case 1:
if spacePieceIndex/dimension != dimension-1 {
moveIndex = spacePieceIndex + dimension
}
case 2:
if spacePieceIndex%dimension != 0 {
moveIndex = spacePieceIndex - 1
}
case 3:
if spacePieceIndex%dimension != dimension-1 {
moveIndex = spacePieceIndex + 1
}
}
a[spacePieceIndex], a[moveIndex] = a[moveIndex], a[spacePieceIndex]
spacePieceIndex = moveIndex
loop--
}
return a
}
func makeRange(min, max int) []int {
a := make([]int, max-min+1)
for i := range a {
a[i] = min + i
}
return a
}
js回填交换位置部分:
typescript
const piecesShuffle = async (pieces: PiecePosition[]) => {
const tempPieces: PiecePosition[] = JSON.parse(JSON.stringify(pieces))
// 模拟真实操作以防止出现无法还原的情况
// 随机步数
// 目前3-7是比较舒适的范围
const loop = dimension.value ** dimension.value * dimension.value
const shuffleArr = await GenerateRandomPieces(dimension.value, loop)
return tempPieces.map((item, index) => {
const targetSerial = shuffleArr[index]
// 标记空格
if (piecesPosition.value[targetSerial].status === "space") {
spaceTarget.value = item.serial
}
return {
serial: item.serial,
status: piecesPosition.value[targetSerial].status,
position: {
...piecesPosition.value[targetSerial].position,
},
}
})
}
js和css精度丢失问题
首先js浮点数本身存在的精度丢失就不说了,绑定的position变量经历了一系列计算,并且在style也会存在一定的精度丢失问题,因此我们要解决两个问题:
-
- 计算碰撞的时候可能因为精度导致计算结果认为两个相邻的方块就是碰撞了而无法移动。这部分我通过留出1px的冗余距离解决。在之前碰撞算法的代码已经贴出;
-
- 第1点留出的冗余和精度丢失都会一定程度上导致第二个问题,最终方块不能完全移入空位,也就导致了其他方块无法正常移动。何况玩家也不一定能精准的将方块完全移入空位。有一些肉眼难以分辨的距离更是让玩家充满疑惑,"明明我都移进去了,怎么动不了?是不是卡死了?"所以我们需要在结束移动后,设置一个依附距离,一旦进入这个距离,我们直接就把方块"啪"地一下弹进最近的空位里。
我把这个方法叫做getPiecePa。是不是很形象?
typescript
const getPiecePa = (val: number) => {
const attachDistance = 15
const trans = Math.ceil(val)
const module = trans % itemLength.value
if (module < attachDistance) {
return trans - module
} else if (itemLength.value - module < attachDistance) {
return trans - module + itemLength.value
}
return val
}
我图图呢?
华容道没有图片玩个der呢?
所以浅浅考虑一下决定加入一下图片切割算法。同样分成两部分:
- 裁剪图片成正方形
- 将正方形图片分割成dimension ** 2片
因为裁剪这部分需要由用户来选择合适的区域,所以选择了放在前端完成,这边也是不造轮子(肯定不是我懒)直接用vue-cropper来裁剪图片。顺带吐槽一下vue-cropper,之前遇到一个问题看了下issue有人遇到相同的,结果作者团队把人拉进微信群里就把issue关了,然而微信群二维码才9天有效期,我还没上车啊! 这部分的代码就不贴了。
切割这部分我是交给了go做,正好练习下image相关的api。用js的话当然也可以使用canvas。
将图片转成base64后传给go,然后go切割完再转成base64还给前端。
go
func CutImageToPieces(base64Str string, imgType string, dimension int) ([]string, error) {
decodedBytes, _ := base64.StdEncoding.DecodeString(base64Str)
// image
var m image.Image
var err error
switch imgType {
case "jpeg":
m, err = jpeg.Decode(bytes.NewReader(decodedBytes))
case "jpg":
m, err = jpeg.Decode(bytes.NewReader(decodedBytes))
case "png":
m, err = png.Decode(bytes.NewReader(decodedBytes))
default:
err = errors.New("图片类型错误")
}
if err != nil {
return []string{}, err
}
b := m.Bounds()
// 边长
length := int(math.Min(float64(b.Max.X), float64(b.Max.Y)))
// step
step := length / dimension
// 减除多余部分
length = length - length%step
// 切割后的base64图片数组
imgArr := make([]string, dimension*dimension)
var wg sync.WaitGroup
for x := 0; x < length; x += step {
for y := 0; y < length; y += step {
wg.Add(1)
go func(x, y int) {
defer wg.Done()
rect := image.Rect(x, y, x+step, y+step)
subImg := image.NewRGBA(rect)
draw.Draw(subImg, rect, m, rect.Min, draw.Src)
subImgBuffer := new(bytes.Buffer)
err := png.Encode(subImgBuffer, subImg)
if err != nil {
return
}
base64Str := base64.StdEncoding.EncodeToString(subImgBuffer.Bytes())
index := y/step*dimension + x/step
imgArr[index] = base64Str
}(x, y)
}
}
wg.Wait()
return imgArr, nil
}
维护游戏状态
看得出来整个游戏有如下几个操作:
- 初始化方块的宽高和位置
- 打乱方块位置
- 导入图片并切割
我们用一个枚举类维护下游戏的状态便于各个阶段的操作和控制以及后续的拓展:
typescript
enum GameStatus {
Wait,
Ready,
Play,
End,
Loading,
}
利用状态机的思想控制整个游戏的进行。
存在的问题
不知道是因为vue的响应式更新dom的问题,还是我写的代码的问题,在某些情况下拖拽方块不能到底,而是会在碰撞前的一小段距离卡住。
最终效果
容我想想怎么展示,当初就不应该用go完成一部分功能···
虽然不能在线给jy玩下,总之先贴个gif吧。
最后吐槽下掘金的编辑器,才400行不到怎么感觉卡的p爆