前言
最近的日子好忙,好在游戏活动结束之前,小包还是完成了最初的设想,这个游戏大家都不陌生(原谅小包先卖个关子),是一个非常经典的游戏,但小包将原来的二维游戏转化为三维游戏,下面我们来一起瞅瞅吧。
游戏介绍
游戏名称: 捉迷藏的运营大大们
游戏内容:
- 游戏版图为
10*10
的立体网格,网格左键点击后可以翻转,右键点击可以标注🚩
。 - 运营都藏在网格的底面,运营周围的格子会有数字提示,数字表示当前格子周围运营的数量(是不是有些熟悉),根据数字提示可以推理出运营位置。
- 运营的数量,嘿嘿,小包不告诉你。
- 当找到或标注所有的运营位置后,游戏胜利。
- 如果你点击到运营,游戏失败。
设计思想:
- 很早之前小包就想实现
3D
扫雷效果,但没有找到很好的创意,前几天小包看朋友玩游戏,有个翻牌环节,然后本游戏就诞生了 —— 翻牌式扫雷游戏。 - 来到掘金社区也快半年之久了,参加活动,写技术文章,接触到好多可爱负责的运营,因此小包转变了这个游戏的设定,使用运营来替换雷的概念,你能找到这些可爱负责的运营们吗?(给可爱负责的运营大佬们打
call
)
素材:
素材选自掘金运营大大的头像,加个小问题,看头像识运营,你能认出多少?
游戏中使用了运营大大们的头像,如涉及侵权请联系小包
游戏预览:
游戏体验
欢迎大家体验立体寻运营游戏。你可以有两种体验方式:
游戏设计
我们首先来分析一下这个游戏:
- 游戏地图由
10 * 10
个网格构成,每个网格是一个长方体,长方体的底面存放运营图片或者周围运营数量的提示,顶面可以标记🚩
。 - 点击网格后,网格会发生翻转
布局方面实现,小包认为有以下亮点:
- 长方体网格实现
- 网格布局
- 底面与顶面显示
长方体格子
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); } 复制代码
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); } 复制代码
然后我们就可以成功的实现一个立体格子。
网格布局
游戏共有 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); } 复制代码
顶面与底面的显示
底面显示效果算是游戏的难点之一,底面元素有三种情况:
- 运营
- 数字提示
- 空白
我们如何来标识这个区别呐?
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.random
与 0.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
进行了测试,最终结果如下:
我们可以看到,所有乱序的结果是非常接近的,乱序算法不选他选谁。
为什么需要乱序
上面讲解了乱序的实现,大家有可能会奇怪,我们这个项目中为什么会使用乱序算法呐?小包设计游戏之初选择了 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寻找运营游戏源码
如果感觉有帮助的话,别忘了给小包点个 ⭐ 。