我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛
前言
恰逢春之四月,天气忽热忽凉,遇游戏大赛,以笨拙之技,书一篇小文。
游戏规则:2048。
环境
主要环境:
vue3 version:3.2.4
vite version:2.5.0
主要插件:
windicss version:3.5.1
mitt version:3.0.0
预览:vaggchen.github.io/2048plus/
运行如图:
思路
- 搭建环境,下载依赖
- 运行项目
- 利用windicss主体兼容pc和移动端 姑且认为小于1024的是平板或者手机 lg(1024px)
App.vue
<div class="relative w-full h-full lg:(w-750px h-800px)">
<Game />
</div>
- 主体Game.vue设置3个主体组件:GameTool.vue游戏工具栏(重新开始和得分统计&最高分)、GameCnt.vue游戏主体、GameOver.vue游戏结束
实现
GameOver
提示 游戏结束 且 输出最终得分
GameTool
- Logo
- 重新开始
- 得分
- 最高分 使用的是本地持久缓存
logo来源百度 ,重新开始按钮来源iconFont
GameCnt
布局
- 先画出4*4的格子,这里有多种方法,笔者这里采取最简单的动态grid布局实现,
- 宽高获取,这里要获取,原因是想让宽高一致,但每个手机不一样,所以使用的动态获取
- 设置lazyShow,让第一次渲染不会有transform动画
// 定义行个数
const rowLen = 4
// 定义cnt宽高和item的宽高
// 定义cnt
const cnt = ref(null)
const cntWidth = ref(0)
const itemWidth = ref(0)
// lazy show
const lazyShow = ref(true)
//获取dom和渲染
onMounted(() => {
getCntWidth()
lazyShow.value = false
})
// 获取宽高
const getCntWidth = () => {
cntWidth.value = cnt.value.clientWidth
itemWidth.value = (cnt.value.clientWidth - 2 * 16) / rowLen
}
<!-- 展示底部格子 -->
<div v-show="!lazyShow" v-for="(item, index ) in boxLists" class="box rounded-md "
:style="{ width: itemWidth + 'px', height: itemWidth + 'px' }">
</div>
实现随机格子
2048一般来说都是有随机生成2/4值的格子
- 生成随机数 通过Math.random() > 0.7 ? 2 : 4,这个数值越接近1越难,因为2越多,后期越难处理
- 生成随机序号
- 这个地方我的第一版使用的是Math.random()生成随机序号,判断随机序号所在值是否为0,不是则重新来过但其实有很大的效率问题
- 第二版使用的是数组的
filter方法筛选出所有为0的格子,然后使用Math.random()和当前筛选出数组长度生成一个随机序号,再返回真实的序号即可,如代码所示
// 生成 2或4
const randomNums = () => {
console.log('randomNums')
...略
// 生成随机数 生成2的概率越大越难
const num = Math.random() > 0.7 ? 2 : 4
// 生成随机序号的方法
const createIndex = () => {
// 筛选剩余空位
let arr = valLists.value.filter(item => item.val === 0)
console.log(arr)
// 随机一个序号
const index = parseInt(Math.random() * arr.length)
// 返回真实的序号值
return arr[index].originKey
}
// 生成随机序号
const index = createIndex()
// 放入数组
valLists.value[index] = {
val: num,
originKey: index,
left: (index % rowLen) * (itemWidth.value + 10),
top: parseInt(index / rowLen) * (1 / rowLen) * cntWidth.value,
}
}
判断是否游戏结束
2048游戏结束有两个条件
-
盘面没有空位
- valLists.value.every(item => item.val !== 0)即可
-
每个数字上下左右都不相同
- 必须判断每个数值上下左右是否相同,这个地方因为时间问题,暂时没做优化了,其实这个地方有判断重复的
-
需要2次判断
- 在移动后生成随机数前判断一次
- 在生成随机数后判断一次 核心代码:
const isGameOver = () => {
// 如果不存在0时
if (valLists.value.every(item => item.val !== 0)) {
// 且每一个元素上下左右都不同
let noSame = true
for (let i = 0; i < rowLen * rowLen; i++) {
// 该元素之上的值如果相等 则跳出
let topInxdex = i - rowLen;
if (topInxdex > -1 && (valLists.value[i].val === valLists.value[topInxdex].val)) {
noSame = false
break
}
// 该元素之下的值如果相等 则跳出
let botInxdex = i + rowLen;
if (botInxdex < valLists.value.length && (valLists.value[i].val === valLists.value[botInxdex].val)) {
noSame = false
break
}
// 该元素之左的值如果相等 则跳出
let leftInxdex = i - 1;
if (leftInxdex > -1 && (parseInt(i / rowLen) === parseInt(leftInxdex / rowLen)) && (valLists.value[i].val === valLists.value[leftInxdex].val)) {
noSame = false
break
}
// 该元素之右的值如果相等 则跳出
let rightInxdex = i + 1;
if (rightInxdex < valLists.value.length && (parseInt(i / rowLen) === parseInt(rightInxdex / rowLen)) && (valLists.value[i].val === valLists.value[rightInxdex].val)) {
noSame = false
break
}
}
if (noSame) {
return true
}
}
return false
}
实现滑动监听
相关代码位于src/utils/listenTouch.js
- 先监听touchstart(监听一定要带上参数
{passive: false},加上e.preventDefault()阻止浏览器默认),记录触屏开始的pageX和pageY
阻止默认好处是,移动端浏览器滑动时不会触发浏览器的滑动监听,导致页面乱跑
debounce的加入使得滑动不会触发的太频繁
document.addEventListener("touchstart", debounce(function(e) {
// 阻止浏览器默认
e.preventDefault()
startx = e.touches[0].pageX;
starty = e.touches[0].pageY;
}), {passive: false});
- 监听手指离开屏幕touchend,再根据触屏离开和触屏开始的点的坐标差,计算出滑动角度(不太精确的一种方案) ,然后根据角度可以通知游戏主体滑动方向,这里使用的是mitt事件线程通知 核心代码:
var angle = getAngle(angx, angy);
//获得角度
function getAngle(angx, angy) {
return Math.atan2(angy, angx) * 180 / Math.PI;
};
if (angle >= -135 && angle <= -45) {
result = 1;
} else if (angle > 45 && angle < 135) {
result = 2;
} else if ((angle >= 135 && angle <= 180) || (angle >= -180 && angle < -135)) {
result = 3;
} else if (angle >= -45 && angle <= 45) {
result = 4;
}
- 调用事件触发
var direction = getDirection(startx, starty, endx, endy);
switch (direction) {
case 0:
// alert("未滑动!");
break;
case 1:
// alert("向上!")
EventBus.$emit('touchToTop')
break;
case 2:
// alert("向下!")
EventBus.$emit('touchToBot')
break;
case 3:
// alert("向左!")
EventBus.$emit('touchToLeft')
break;
case 4:
// alert("向右!")
EventBus.$emit('touchToRight')
break;
default:
}
画出含值方块儿
这个地方想有个动画,就使用的是transform实现,于是格子需要item.moveIndex确定位置
// 定义数组
const valLists = ref([])
valLists.value = new Array(rowLen * rowLen).fill(0).map((item, index) => ({
val: 0,
moveIndex: index
}))
<!-- 展示值 -->
<div v-show="!lazyShow" v-for="(item, index ) in valLists"
class="val rounded-md absolute flex justify-center items-center p-2"
:class="[item.val ? getClass(item.val) : 'hide', item.class]" :style="{
left: `${item.left}px`, top: `${item.top}px`, width: itemWidth + 'px', height: itemWidth + 'px'
}">
<!-- {{ item.val ? item.val : '' }} -->
<img :src="getSrc(item.val)" alt="">
</div>
含值方块移动
这个地方是本游戏的核心,上下左右都差不多,主要是方法问题
- 使用纵向遍历,遍历每一层
- 寻找可以合并的值,进行数字移动动画,即滑动元素的left改变,设置条件取消动画和滑动元素left的还原和值重置为空,被合并元素值加倍
- 使用arr记录每一层的数值
- 根据方向进行去0规整数组
- 例向左则:[0, 4, 0, 4] => [4, 4, 0, 0]
- 然后动画将每一层的方块进行动画调整左坐标或者上坐标
使用requestAnimationFrame完成动画效果,cancelAnimationFrame取消动画
// 监听滑动向左
EventBus.$on('touchToLeft', () => {
console.log('touchToLeft')
let flag = arrToLeft()
continueGame(flag)
})
// 数组向左的事件
const arrToLeft = () => {
// 默认没有发生移动
let isMove = false
// 遍历
for (let i = 0; i < rowLen; i++) {
let arr = new Array(rowLen).fill(0)
for (let j = 0; j < rowLen - 1; j++) {
// 获取真实数组的序号
let index = i * rowLen + j
// 获取当前
let tempItem = valLists.value[index]
// 获取当前下一个
let tempItemNext = valLists.value[index + 1]
arr[j] = tempItem.val
arr[j + 1] = tempItemNext.val
// 判断能否合并
if (tempItem.val === tempItemNext.val && tempItem.val !== 0) {
let animate
// 动画
const animLoop = () => {
tempItemNext.left -= (tempItemNext.left - tempItem.left) / addRatio
animate = window.requestAnimationFrame(animLoop)
// 结束循环条件 ,偏移量是为了纠正移动轨迹
if (tempItemNext.left <= tempItem.left + addOffset) {
// 取消动画
cancelAnimationFrame(animate)
// 动画后赋值
tempItemNext.left = (tempItemNext.originKey % rowLen) * (itemWidth.value + 10)
tempItemNext.val = 0
tempItem.val = tempItem.val * 2
tempItem.class = 'mix'
}
}
tempItem.class = ''
animLoop()
arr[j] = tempItem.val * 2
arr[j + 1] = 0
// 合并即发生了移动
isMove = true
// 分数改变事件
scoreChange(arr[j])
j++
// 如果和为rowLen - 2,弥补边界问题
if (j === rowLen - 2) {
arr[j + 1] = valLists.value[i * rowLen + j + 1].val
}
}
// 末尾开始移动0位的
if (j === rowLen - 2 || j === rowLen - 1) {
console.log(arr)
let sort = 0
arr.forEach((it, zIndex) => {
// 记录新的zIndex
let index2 = i * rowLen + zIndex
// 记录当前对象
let newItem = valLists.value[index2]
// 如果下标不匹配,且arr[zIndex]当前不为0
if (sort !== zIndex && it !== 0) {
// 是否发生移动
isMove = true
}
if (it !== 0) {
// 存贮目的地index
let lastIndex = i * rowLen + sort
// 存贮目的地左距离
let lastLeft = valLists.value[lastIndex].left
let animate
// 动画
const animLoop = () => {
newItem.left -= (newItem.left - lastLeft) / moveRatio
animate = window.requestAnimationFrame(animLoop)
// 结束循环条件 ,偏移量是为了纠正移动轨迹
if (newItem.left <= lastLeft + moveOffset) {
// 解除动画
cancelAnimationFrame(animate)
// 渲染正确的值并恢复之前的渲染
newItem.left = (newItem.originKey % rowLen) * (itemWidth.value + 10)
newItem.val = 0
valLists.value[lastIndex].val = it
}
}
// 如果发生序号错位情况,要么移动,要么合并了,再执行动画
sort !== zIndex && animLoop()
sort++
}
})
}
}
}
return isMove
}
注:有想法可以沟通下,一起提升!
使用图片替换数值
使用map对象键值对应设定的,暂时不考虑大于4096的,应该没人这么厉害吧!
// 生成对应src
const getSrc = (val) => {
let map = {
2: haicaoPng,
4: xiaPng,
8: xiaoyuPng,
16: jinliyuPng,
32: wuguiPng,
64: shuimuPng,
128: zhangyuPng,
256: haitunPng,
512: shayuPng,
1024: jingyuPng,
2048: longPng,
4096: supermanPng,
}
return val in map ? map[val] : '-'
}
最后
GameCnt 可以写一些测试代码,方便测试下所有块级颜色啥的,上线记得注释
GameCnt 的块级效果优化过,否则只有颜色,不太好看
Game.tool 已加入提示功能
listenTouch加入debounce防止事件频繁触发
动画使用的window.requestAnimationFrame,这个动画也做了兼容处理
整体难度中等,可以练习下vue3,以及游戏思维
笔者比较菜,感觉游戏动画还有问题,欢迎各位大佬建议和试玩!