我正在参加掘金社区游戏创意投稿大赛团队赛,详情请看:游戏创意投稿大赛
我正在参加 码上掘金体验活动,详情:show出你的创意代码块
前言
最近的日子好忙,好在游戏活动结束之前,小包还是完成了最初的设想,这个游戏大家都不陌生(原谅小包先卖个关子),是一个非常经典的游戏,但小包将原来的二维游戏转化为三维游戏,下面我们来一起瞅瞅吧。
游戏介绍
游戏名称: 捉迷藏的运营大大们
游戏内容:
- 游戏版图为
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寻找运营游戏源码
如果感觉有帮助的话,别忘了给小包点个 ⭐ 。
后语
我是 战场小包 ,一个快速成长中的小前端,希望可以和大家一起进步。
如果喜欢小包,可以在 掘金 关注我,同样也可以关注我的小小公众号——小包学前端。
一路加油,冲向未来!!!