用Vue+TailWindcss实现一个你追我赶的闯关小游戏~

我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛

游戏介绍

开头 (1).gif

这是一款2d益智闯关游戏,玩家须躲避敌人与陷阱到达终点 拥有多个关卡 image.png

可进行关卡的自定义并留存数据

20220409_165349.gif

实现技术

vue tailwindcss

本游特色

  • 自定义关卡
  • 敌人自动索敌
  • 低技术力
  • you win!

技术实现

初始化页面

创建一个json文件,用来存放初始关卡的变量(只有一关。。。) 为方块设定大小,初始化变量speed设置为176,棋盘的宽高就各位4个speed,方块宽高就是1个speed,方块移动一格就是speed * 1,两格就是speed * 2

<!-- 棋盘 -->
<div :style="{ width: `${speed * 4}px`, height: `${speed * 4}px` }">
    <!-- 每一个小方块 -->
    <div :style="{ width: `${speed}px`,height: `${speed}px`,}"></div>
</div>
复制代码
const speed = ref(176);
复制代码

Level是一个json文件,里面放着第一关的各种变量,用来在没有关卡的时候初始化一个关卡

level.json

[
    {
        "id": 1,// 第一关
        "speed": 176,// 方块大小
        "top": 528,// 主角top值
        "left": 0,// 主角left值
        "enemy_top": 0,// 敌人top值
        "enemy_left": 352,// 敌人left值
        "enemy_top_2": 528,// 敌人2的top值
        "enemy_left_2": 352,// 敌人2的left值
        "obstacle_top": 176,// 障碍top值
        "obstacle_left": 352,// 障碍left值
        "trap_top": 352,// 陷阱top值
        "trap_left": 176,// 陷阱left值
        "spot_top": 0,// 终点top值
        "spot_left": 528// 终点left值
    }
]
复制代码

在加载页面的时候判断是否有数据如果没有的话添加

import Level from "../../api/level.json";
let res = JSON.parse(localStorage.getItem("data"));
if (!res) {
    localStorage.setItem("data", JSON.stringify(Level));
}
复制代码

小方块设置

使用绝对定位,用transition-all让方块看起来有动画效果

<div class="absolute transition-all"></div>
复制代码

为小方块设置特定的top和left,声明变量然后设置给小方块上

<!-- 终点,我用的spot前缀 -->
<div :style="{ top: `${spot_top}px`,left: `${spot_left}px` }"></div>
<!-- 敌人,我用的enemy前缀(敌人2后缀直接-2) -->
<div :style="{ top: `${enemy_top}px`,left: `${enemy_left}px` }"></div>
复制代码
const Level = JSON.parse(localStorage.getItem("data"));
const spot_top = ref(Level[index].spot_top);
const spot_left = ref(Level[index].spot_left);
const enemy_top = ref(Level[index].enemy_top);
const enemy_left = ref(Level[index].enemy_left);
复制代码

主角移动

当按下相应按键后执行相应的函数

document.addEventListener("keydown", (e) => {
  switch (e.key) {
    case "a":
      if (is_run.value) {
        moveProtagonistA();
      }
      break;
    case "w":
      if (is_run.value) {
        moveProtagonistW();
      }
      break;
    case "d":
      if (is_run.value) {
        moveProtagonistD();
      }
      break;
    case "s":
      if (is_run.value) {
        moveProtagonistS();
      }
      break;
    case "r":
      againGame();// 重新开始
      break;
  }
});
复制代码

四个函数的意思分别是主角块上下左右的移动,本质其实都差不多,差别就在于每个的top和left是不同的,所以咱就挑一个详细说明一下:

当想让主角向左移动时

const moveProtagonistA = () => {
  // 自杀判断
  if (
    left.value == enemy_left.value + speed.value &&
    top.value == enemy_top.value
  ) {
    left.value -= speed.value;
    return false;
  }
  if (left.value == 0) {
    // 边界判断
    left.value = -20;
    setTimeout(() => {
      left.value = 0;
    }, 100);
    return false;
  }
  // 障碍判断
  obstacle = obstacle_left.value + speed.value;
  if (top.value == obstacle_top.value && left.value == obstacle) {
    left.value = obstacle - 20;
    setTimeout(() => {
      left.value = obstacle;
    }, 100);
  } else {
    left.value -= speed.value;
    freeFindEnemy(enemy_top, enemy_left);
    freeFindEnemy(enemy_top_2, enemy_left_2);
  }
};
复制代码

函数整体的内容有点小多,咱们来分开解释:

自杀判断

因为在主角移动时,敌人的自动索敌功能也会开启,所以导致当主角向敌人移动的时候因为敌人自动索敌的原因会与主角错开,于是便诞生了这个逻辑,就是判断如果主角的下一步有敌人的话,敌人原地不动,装上敌人game over

 // 自杀判断
  if (
    left.value == enemy_left.value + speed.value &&
    top.value == enemy_top.value
  ) {
    left.value -= speed.value;
    return false;// 如自杀成功则阻止下面的索敌判断
  }
复制代码

20220409_165710.gif

边界判断

如果出界会被拦截并且给一个被拦截的效果提示,因为这个示例是想左移动的时候,所以判断条件也是左边

if (left.value == 0) {
    // 这个效果可以让方块回弹一下
    left.value = -20;
    setTimeout(() => {
      left.value = 0;
    }, 100);
    return false;// 如果碰到边界则阻止像下面的索敌判断
  }
复制代码

20220409_165932.gif

障碍判断 && 索敌

如果关卡中存在障碍的话,当主角触碰到障碍的时候,会跟边界判断拥有一样回弹效果来提示此路不通

如果主角移动没有碰到障碍阻拦的话,则执行正常移动的命令并且执行自动索敌

obstacle = obstacle_left.value + speed.value;
if (top.value == obstacle_top.value && left.value == obstacle) {
    // 跟上面一样,回弹一下
  left.value = obstacle - 20;
  setTimeout(() => {
    left.value = obstacle;
  }, 100);
} else {
  left.value -= speed.value;// 移动命令
  freeFindEnemy(enemy_top, enemy_left);// 敌人1的索敌
  freeFindEnemy(enemy_top_2, enemy_left_2);// 敌人2的索敌
}
复制代码

也许你已经看到了(朵拉摆手),在索敌的最后使用的两个函数,这个函数就是自动索敌的逻辑,接下来继续深入~

自动索敌

当主角移动时敌人自动索敌

// 自动索敌
const freeFindEnemy = (Etop: any, Eleft: any) => {
  let _top = top.value - Etop.value;
  let _left = left.value - Eleft.value;
  if (Math.abs(_top) > Math.abs(_left)) {
    if (_top > 0) {
      moveEnemyS(Etop, Eleft);
    } else {
      moveEnemyW(Etop, Eleft);
    }
  } else {
    if (_left > 0) {
      moveEnemyD(Etop, Eleft);
    } else {
      moveEnemyA(Etop, Eleft);
    }
  }
};
复制代码

这个里面出现的函数moveEnemy系列是敌人方块的方向移动,逻辑就是判断主角距离敌人的top和left来决定敌人方块的走向,Etop与Eleft需要分别传入的敌人的top和left值,判断拿边距离大就往哪边行动,有大于、小于等于两种情况

由自动索敌又延申出了--敌人移动

敌人移动

敌人移动也是拥有四个函数,基本与主角移动没有区别,但是敌人在碰到障碍的时候会选择绕开,且敌人碰到陷阱的时候会被“吃掉”

拿敌人向下移动来举例

const moveEnemyS = (Etop: any, Eleft: any) => {
  // 陷阱判断
  if (trap_top.value == Etop.value && trap_left.value == Eleft.value) return;
  // 障碍检测判断
  obstacle = obstacle_top.value - speed.value;
  if (Etop.value == obstacle && Eleft.value == obstacle_left.value) {
    // 判断如果碰到障碍
    let _left = left.value - Eleft.value;
    if (_left > 0) {
      Eleft.value += speed.value;
    } else {
      Eleft.value -= speed.value;
    }
  } else {
    Etop.value += speed.value;
  }
};
复制代码

首先是陷阱的判断,如果敌人的top和left与陷阱一致的话则判断敌人掉进了陷阱里,将终止敌人的所有移动

接下来是障碍,判断如果敌人即将要走的方向有障碍挡着的话,就去判断与主角的距离来向左或者向右避开

胜利与失败

在胜利和失败后肯定是要终止所有行动的,正好所有的行动也是由主角移动的函数来触发的,所以先声明一个变量用来控制游戏的进行,然后通过按键在判断这个变量,如果游戏正在进行中则触发移动函数函数,如果游戏未开始或已失败则跳过触发事件,即无响应

case "a":
  // is_run即声明的变量,在游戏失败或未开始阶段该变量为false
  if (is_run.value) {
    moveProtagonistA();
  }
  break;
复制代码

当胜利条件符合(即主角碰到终点)时,触发win,即显示win字样并使is_run置为false

image.png

// 主角的topleft是否与终点的topleft重合
if (top.value == spot_top.value && left.value == spot_left.value) {
    winShow.value = true;
    is_run.value = false;
  }
复制代码

当失败条件符合(即主角碰到敌人1或2或者陷阱)时,触发lose,即显示lose字样并使is_run置为false

image.png

if (
    (top.value == enemy_top.value && left.value == enemy_left.value) ||
    (top.value == enemy_top_2.value && left.value == enemy_left_2.value) ||
    (top.value == trap_top.value && left.value == trap_left.value)
  ) {
    is_run.value = false;
    loseShow.value = true;
    return;
  }
复制代码
  • 最后一个return的作用是截断,当触发了lose后就不再继续执行了(否则会接着执行win)

编辑关卡

移入移出变色

16个黑块,通过鼠标移入移出判断颜色

<div
  v-for="(item, index) in blockList"
  :key="index"
  :style="{
    width: `${speed}px`,
    height: `${speed}px`,
    background: item.background,
  }"
  @mousemove="editMove($event, item)"
  @mouseleave="editLeave"
  class="transition-all"
></div>
<!-- transition-all使样式变换具有过渡效果 -->
复制代码
const editMove = (event, item) => {
  // 如果该方块已经被选中则什么都不做
  if (!item.is_confirm) {
    for (let i in blockList.value) {
      // 选中相应的方块进行变色
      if (blockList.value[i].id == item.id) {
        blockList.value[i].background = "";
      } else if (blockList.value[i].is_confirm) {
        blockList.value[i].background = "";
      } else {
        blockList.value[i].background = "#000";
      }
    }
  }
};
const editLeave = () => {
  for (let i in blockList.value) {
    // 如果该方块已经被选中则什么都不做
    if (blockList.value[i].is_confirm) {
      blockList.value[i].background = "";
    } else {
       // 选中相应的方块进行变色
      blockList.value[i].background = "#000";
    }
  }
};
复制代码

因为方块被设置后是不能被改变颜色的,所以需要这两个方法对已经被设置的方块进行判断

点击设置

需先点击左侧图例使颜色选中,再点击方块使其变色

20220409_170214.gif

图例

<div
  v-for="(item, index) in legendList"
  :key="index"
  class="flex mb-4 items-center text-xl"
  @click="colorClick($event, item)"
>
  <div class="legend_sign" :class="item.color"></div>
  <div class="w-10"></div>
  <div
    class="transition-all p-2 rounded-lg"
    :class="color == item.color ? color : ''"
  >
    {{ item.introduce }}
  </div>
</div>
复制代码
const legendList = [
  {
    id: 0,
    color: "bg-green-500",
    introduce: "终点",
  },
  {
    id: 1,
    color: "bg-red-500",
    introduce: "敌人",
  },
  {
    id: 2,
    color: "bg-blue-500",
    introduce: "主角",
  },
  {
    id: 3,
    color: "bg-gray-500",
    introduce: "障碍",
  },
  {
    id: 4,
    color: "bg-purple-500",
    introduce: "陷阱",
  },
];
复制代码

变色逻辑

<!-- 跟移入移出变色的div是同一个div -->
<!-- 重点看这句::class="item.color" -->
<div
  v-for="(item, index) in blockList"
  :key="index"
  :style="{
    width: `${speed}px`,
    height: `${speed}px`,
    background: item.background,
  }"
  :class="item.color"
  @click="editClick($event, item)"
  @mousemove="editMove($event, item)"
  @mouseleave="editLeave"
  class="transition-all"
></div>
复制代码
const editMove = (event, item) => {
  if (!item.is_confirm) {
    for (let i in blockList.value) {
      if (blockList.value[i].id == item.id) {
        // 重点在这两句
        blockList.value[i].background = "";
        blockList.value[i].color = color.value;
      } else if (blockList.value[i].is_confirm) {
        blockList.value[i].background = "";
      } else {
        blockList.value[i].background = "#000";
      }
    }
  }
};
const editClick = (event, item) => {
  // json添加
  switch (color.value) {
    case "bg-green-500":
      if (json.spot_top != 9999) {
        tips.value = "终点只能有一个";
        return;
      }
      json.spot_top = item.top;
      json.spot_left = item.left;
      break;
    case "bg-red-500":
      if (json.enemy_top != 9999) {
        if (json.enemy_top_2 != 9999) {
          tips.value = "敌人只能有两个";
          return;
        }
        json.enemy_top_2 = item.top;
        json.enemy_left_2 = item.left;
        break;
      }
      json.enemy_top = item.top;
      json.enemy_left = item.left;
      break;
    case "bg-blue-500":
      if (json.top != 9999) {
        tips.value = "主角只能有一个";
        return;
      }
      json.top = item.top;
      json.left = item.left;
      break;
    case "bg-gray-500":
      if (json.obstacle_top != 9999) {
        tips.value = "障碍只能有一个";
        return;
      }
      json.obstacle_top = item.top;
      json.obstacle_left = item.left;
      break;
    case "bg-purple-500":
      if (json.trap_top != 9999) {
        tips.value = "陷阱只能有一个";
        return;
      }
      json.trap_top = item.top;
      json.trap_left = item.left;
      break;
    default:
      tips.value = "请先选择颜色~";
      return;
  }
  // 状态保留
  for (let i in blockList.value) {
    if (blockList.value[i].id == item.id) {
      blockList.value[i].background = "";
      blockList.value[i].color = color.value;
      blockList.value[i].is_confirm = true;
    } else if (blockList.value[i].is_confirm) {
      blockList.value[i].background = "";
    } else {
      blockList.value[i].background = "#000";
    }
  }
};
复制代码

首先是通过点击图例来保存颜色,然后在鼠标移入黑块的时候不再是白色,而是选中的颜色,在点击的时候能将颜色固定到黑块上

因为style的优先级要比class大(background比bg-red-500大),所以在悬浮时需要将背景颜色去掉:

blockList.value[i].background = "";
blockList.value[i].color = color.value;
复制代码

在点击的时候需要保留这个颜色,所以在点击的时候要将本来的颜色改变,并且在悬浮上去后不会变色

blockList.value[i].background = "";
blockList.value[i].color = color.value;
blockList.value[i].is_confirm = true;
复制代码

is_confirm在上面已经出现过一两次,表示的是这个块是否被设置,如果被设置了则不对它做任何操作

const editMove = (event, item) => {
  if (!item.is_confirm) {
    ...
  }
};
复制代码

保存关卡

对每个被设置的块记住位置,在点击保存关卡的时候将它放到本地存储里,这样一个新的关卡就生成了

【gif保存关卡】

初始时将所有top left全都设置为9999,在点击方块的时候记录方块的top left和颜色来向一个数组中传入数据,并且对块的数量做出限制,这里拿主角来举例:

switch(color.value){
  case "bg-blue-500":
      if (json.top != 9999) {
        tips.value = "主角只能有一个";
        return;
      }
      // 将主角的top lef填入对应的地方
      json.top = item.top;
      json.left = item.left;
      break;
}
复制代码

在点击保存关卡时将数组添加进本地存储

const Level = JSON.parse(localStorage.getItem("data"));
let json = {
  id: Level.length + 1,
  speed: 176,
  top: 9999,
  left: 9999,
  enemy_top: 9999,
  enemy_left: 9999,
  enemy_top_2: 9999,
  enemy_left_2: 9999,
  obstacle_top: 9999,
  obstacle_left: 9999,
  trap_top: 9999,
  trap_left: 9999,
  spot_top: 9999,
  spot_left: 9999,
};

...

const saveClick = () => {
  Level.push(json);
  localStorage.setItem("data", JSON.stringify(Level));
  button_text.value = "保存成功";
  router.push("/main");
};
复制代码

至此整个游戏就结束啦,匆匆忙忙赶出来的,也不指望上台面了【笑哭】,如果对你有帮助那就最好啦

猜你喜欢

转载自juejin.im/post/7084527391468421134