运营大大们来捉迷藏了,你能找到他们吗?

5,686 阅读8分钟

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

我正在参加 码上掘金体验活动,详情:show出你的创意代码块

前言

最近的日子好忙,好在游戏活动结束之前,小包还是完成了最初的设想,这个游戏大家都不陌生(原谅小包先卖个关子),是一个非常经典的游戏,但小包将原来的二维游戏转化为三维游戏,下面我们来一起瞅瞅吧。

游戏介绍

游戏名称: 捉迷藏的运营大大们

游戏内容:

  1. 游戏版图为 10*10 的立体网格,网格左键点击后可以翻转,右键点击可以标注 🚩
  2. 运营都藏在网格的底面,运营周围的格子会有数字提示,数字表示当前格子周围运营的数量(是不是有些熟悉),根据数字提示可以推理出运营位置。
  3. 运营的数量,嘿嘿,小包不告诉你。
  4. 当找到或标注所有的运营位置后,游戏胜利。
  5. 如果你点击到运营,游戏失败。

设计思想:

  1. 很早之前小包就想实现 3D 扫雷效果,但没有找到很好的创意,前几天小包看朋友玩游戏,有个翻牌环节,然后本游戏就诞生了 —— 翻牌式扫雷游戏。
  2. 来到掘金社区也快半年之久了,参加活动,写技术文章,接触到好多可爱负责的运营,因此小包转变了这个游戏的设定,使用运营来替换雷的概念,你能找到这些可爱负责的运营们吗?(给可爱负责的运营大佬们打 call)

素材:

素材选自掘金运营大大的头像,加个小问题,看头像识运营,你能认出多少?

游戏中使用了运营大大们的头像,如涉及侵权请联系小包

游戏预览:

game.gif

游戏体验

欢迎大家体验立体寻运营游戏。你可以有两种体验方式:

游戏设计

我们首先来分析一下这个游戏:

  1. 游戏地图由 10 * 10 个网格构成,每个网格是一个长方体,长方体的底面存放运营图片或者周围运营数量的提示,顶面可以标记 🚩
  2. 点击网格后,网格会发生翻转

布局方面实现,小包认为有以下亮点:

  • 长方体网格实现
  • 网格布局
  • 底面与顶面显示

长方体格子

CSS 可以实现简单的长方体,其原理基于 transform-rotate 配合 transform-translate 实现。

<!-- face 为立方体六个面 -->
<div class="cube">
  <div class="face"></div>
  <div class="face"></div>
  <div class="face"></div>
  <div class="face"></div>
  <div class="face"></div>
  <div class="face"></div>
</div>

Step1: 给 cube 添加 preserve-3d 效果

.cube {
  /*设置3d场景*/
  transform-style: preserve-3d;
  /*调整角度,使六个角度都可以被看到*/
  transform: rotateX(-35deg) rotateY(30deg);
  position: relative;
}

Step2: 设置 face 的通用样式

.face {
  height: 50px;
  width: 50px;
  position: absolute;
  /* 设置 transform 的中心 */
  transform-origin: 50% 50%;
}

Step3: 设置顶面

.face:nth-child(1) {
  background-color: wheat;
  transform: rotateY(0deg) translateZ(5px);
}

top.png

Step4: 设置其他面

.face:nth-child(2) {
  background-color: #efca86;
  width: 10px;
  transform: rotateY(90deg) translateZ(45px);
}

.face:nth-child(3) {
  background-color: #fdcb6e;
  transform: rotateY(180deg) translateZ(5px);
}

.face:nth-child(4) {
  background-color: #efca86;
  width: 10px;
  transform: rotateY(270deg) translateZ(5px);
}

.face:nth-child(5) {
  background-color: #efca86;
  height: 10px;
  transform: rotateX(90deg) translateZ(5px);
}

.face:nth-child(6) {
  background-color: #efca86;
  height: 10px;
  transform: rotateX(-90deg) translateZ(45px);
}

然后我们就可以成功的实现一个立体格子。

cube.png

网格布局

游戏共有 10 * 10 个网格构成,可以选择的布局方式有很多,但小包认为 grid 布局最为适合,因此小包设置了如下样式:

.board {
  display: grid;
  grid-gap: 10px;
  /* 行列大小及网格数量 */
  grid-template-columns: repeat(10, 50px);
  grid-template-rows: repeat(10, 50px);
  position: relative;
  top: 20px;
  /* 设置 3d 效果 */
  transform-style: preserve-3d;
  transform: rotateX(50deg) rotateZ(22deg);
}

grid.png

顶面与底面的显示

底面显示效果算是游戏的难点之一,底面元素有三种情况:

  • 运营
  • 数字提示
  • 空白

我们如何来标识这个区别呐?

  • data-id 存放当前网格的横纵坐标
  • data-tile 存放当前是否为运营,如果是运营,则属性值不为空
  • data-num 存放周围运营数量的提示
<div class="cube" data-id="2,8" data-tile="zoe" data-num="1">
  <div class="face"></div>
  <div class="face"></div>
  <div class="face"></div>
  <div class="face"></div>
  <div class="face"></div>
  <div class="face"></div>
</div>

data-tile 是用来标识当前网格是否为运营,因此我们可以通过属性选择器来配置网格显示对应运营的头像。

/* 这样我们就可以任意的设置运营位置 */
[data-tile="captain"] div:nth-child(3) {
  background: #fff8e7 url(./images/captain.png) center center no-repeat;
  background-size: 60px;
}
[data-tile="zoe"] div:nth-child(3) {
  background: #fff8e7 url(./images/zoe.png) center center no-repeat;
  background-size: 60px;
}

其他部分

  • 翻转效果是通过增删类名配合 transition 所实现,小包先预定义了 flipped 类,类中定义了 transition 语法,当点击网格时,给网格添加 flipped 类名,实现翻转效果。
.flipped {
  pointer-events: none;
  transform: rotateY(180deg) translateZ(0);
  // transition 时间不易设置太长,因为当点击网格时会触发多个网格翻转,设置太长会有明显卡顿感
  transition: all 200ms linear;
}
  • 标注 🚩 效果: 通过增删 tile-flagged 类名来区分网格是否被标注,同时配合 innerHTML 修改网格顶部文字实现 🚩 与 空值的切换。

游戏逻辑

乱序算法

常规乱序方法

提到乱序,我们很容易想起这样一种实现方式,基于 Math.random0.5 的比较进行实现,众所周知,Math.random 是伪随机数,这种乱序实现效果其实是非常差的。

var values = [1, 2, 3, 4, 5];
values.sort(function(){
    return Math.random() - 0.5;
});

console.log(values)

具体的测试过程可以参考羽哥文章: JavaScript专题之乱序

测试原理是:将 [1, 2, 3, 4, 5] 乱序 10 万次,计算乱序后的数组的最后一个元素是 1、2、3、4、5 的次数分别是多少。 一次随机的结果为:

[30636, 30906, 20456, 11743, 6259]

Fisher–Yates算法

Fisher–Yates 也被成为洗牌算法,实现思路非常简单: 遍历数组元素,然后将当前元素与以后随机位置的元素进行交换。

// 根据上面的思想,我们就可以写出下列随机算法
function shuffleArray(array) {
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
  return array;
}

羽哥同样对 Fisher–Yates 进行了测试,最终结果如下:

image.png

我们可以看到,所有乱序的结果是非常接近的,乱序算法不选他选谁。

为什么需要乱序

上面讲解了乱序的实现,大家有可能会奇怪,我们这个项目中为什么会使用乱序算法呐?小包设计游戏之初选择了 many 运营,但小包不想每个运营都以固定的顺序,因此小包为运营的顺序添加乱序效果。

const opetarors = shuffleArray([...tileOptions]);

绘制网格

运营捉迷藏由 10 * 10 个格子构成,每个格子为一个长方体网格,我们提前预定义一个网格,渲染时将网格动态插入,同时为每个网格绑定事件处理函数(但这里并不是最佳的解决方案,我们可以通过事件委托的方式来实现)

创建网格的函数

function buildTile(i, j) {
  const tile = clone.cloneNode(true);
  tile.classList.remove("clone");
  tile.classList.add("cube");
  tile.setAttribute("data-id", `${i},${j}`);
  // 左键点击翻转效果
  tile.addEventListener("click", function (e) {
    clickTile(tile);
  });
  tile.oncontextmenu = function (e) {
    e.preventDefault();
    // 标注🚩
    flag(tile);
  };
  return tile;
}

构建整个棋盘

function createBoard() {
  let opeIndex = 0;
  const opetarors = shuffleArray([...tileOptions]);
  for (let i = 0; i < boardSize; i++) {
    for (let j = 0; j < boardSize; j++) {
      board.appendChild(buildTile(i, j));
    }
  }
  tiles = document.getElementsByClassName("cube");
}

数字计算逻辑

数字计算逻辑(网格周围运营数量提示)我认为是当前项目中的核心部分,小包自认为还是实现的比较巧妙。

当前游戏中,小包把运营数量求解集成在运营排布之后,具体做法是这样的:

  • 设置存储运营的数组 bombs 及存放运营提示的数组 numbers
  • 通过随机数生成运营位置,检查当前位置是否已经布置运营
  • 如果未布置,布置运营,同时将该运营周围 8 个格子坐标添加到 numbers 数组中
  • 当所有的运营布置完毕后,开始遍历 numbers 数组,统计每个坐标出现的次数,即为当前坐标周围运营的数目
// 设置运营位置
while (minesNum) {
  var mineIndex = Math.floor(Math.random() * 100);
  let x = Math.floor(mineIndex / 10),
    y = mineIndex % 10;
  let tile = tiles[x * 10 + y];
  // 若当前位置未布置运营
  if (!bombs.includes(`${x},${y}`)) {
    bombs.push(`${x},${y}`);
    tile.setAttribute("data-tile", opetarors[opeIndex++]);
    // 将运营周围的8个格子存储到 numbers 数组中
    if (x > 0) numbers.push(`${x - 1},${y}`);
    if (x < boardSize - 1) numbers.push(`${x + 1},${y}`);
    if (y > 0) numbers.push(`${x},${y - 1}`);
    if (y < boardSize - 1) numbers.push(`${x},${y + 1}`);

    if (x > 0 && y > 0) numbers.push(`${x - 1},${y - 1}`);
    if (x < boardSize - 1 && y < boardSize - 1)
      numbers.push(`${x + 1},${y + 1}`);

    if (y > 0 && x < boardSize - 1) numbers.push(`${x + 1},${y - 1}`);
    if (x > 0 && y < boardSize - 1) numbers.push(`${x - 1},${y + 1}`);
    minesNum--;
  }
}
// 遍历 numbers ,给对应坐标添加运营数量提示
numbers.forEach((num) => {
  let coords = num.split(",");
  let tile = tiles[parseInt(coords[0]) * 10 + parseInt(coords[1])];
  let dataNum = parseInt(tile.getAttribute("data-num"));
  if (!dataNum) dataNum = 0;
  tile.setAttribute("data-num", dataNum + 1);
});

胜利逻辑

扫雷类游戏会有两种成功方式,一种是 🚩 出所有的"运营",另一种是只剩下"运营"位置未翻开。

🚩 出所有的"运营"

当选择 🚩 后,网格上会添加 tile--flagged 类名,下面两个条件达成后,则游戏胜利:

  • 具有 tile--flagged 类名的数量是否等于"运营"数量
  • 🚩 的网格是否全是"运营"
function checkFlagVictory() {
  const flagNum = document.querySelectorAll(".tile--flagged").length;
  // 如果这里都不相同,后面无需判断
  if (flagNum != tileOptions.length) {
    return;
  }
  let cnt = 0;
  for (let tile of tiles) {
    let coordinate = tile.getAttribute("data-id");
    if (
      tile.classList.contains("tile--flagged") &&
      bombs.includes(coordinate)
    ) {
      cnt++;
    }
  }
  let win = cnt === tileOptions.length ? true : false;
  if (win) {
    gameOver = true;
    overlay.classList.remove("hidden");
    infoP.innerHTML = "恭喜你,成功找到所有运营!!!";
  }
}

只剩下"运营"位置未翻开

网格翻转后,对应网格会添加 tile--checked 类名,因此我们可以通过 tile--checked 与是否为"运营"进行判断。

const checkVictory = () => {
  // 两种成功的情况
  let win = true;
  for (let tile of tiles) {
    let coordinate = tile.getAttribute("data-id");
    if (
      !tile.classList.contains("tile--checked") &&
      !bombs.includes(coordinate)
    ) {
      win = false;
    }
  }
  if (win) {
    gameOver = true;
    overlay.classList.remove("hidden");
    infoP.innerHTML = "恭喜你,成功找到所有运营!!!";
  }
};

源码仓库

源码地址: 3d寻找运营游戏在线体验

项目地址: 3d寻找运营游戏源码

如果感觉有帮助的话,别忘了给小包点个 ⭐ 。

后语

我是 战场小包 ,一个快速成长中的小前端,希望可以和大家一起进步。

如果喜欢小包,可以在 掘金 关注我,同样也可以关注我的小小公众号——小包学前端

一路加油,冲向未来!!!

疫情早日结束 人间恢复太平