前端游戏巨制! CSS居然可以做3D游戏了

很简单的布局, 其中linexlineylinez是我画的坐标轴辅助线.

红线为X轴, 绿线为Y轴, 蓝线为Z轴. 接着我们来看下正方体的主要CSS代码.

.box-con{

width: 50px;

height: 50px;

transform-style: preserve-3d;

transform-origin: 50% 50%;

transform: translateZ(25px) ;

transition: all 2s cubic-bezier(0.075, 0.82, 0.165, 1);

}

.wall{

width: 100%;

height: 100%;

border: 1px solid #fdd894;

background-color: #fb7922;

}

.wall:nth-child(1) {

transform: translateZ(25px);

}

.wall:nth-child(2) {

transform: rotateX(180deg) translateZ(25px);

}

.wall:nth-child(3) {

transform: rotateX(90deg) translateZ(25px);

}

.wall:nth-child(4) {

transform: rotateX(-90deg) translateZ(25px);

}

.wall:nth-child(5) {

transform: rotateY(90deg) translateZ(25px);

}

.wall:nth-child(6) {

transform: rotateY(-90deg) translateZ(25px);

}

复制代码

粘贴一大堆CSS代码显得很蠢.

其他CSS这里就不粘贴了, 有兴趣的同学可以直接下载源码查看. 界面搭建完成如图所示:

WechatIMG308.png

接下来就是重头戏了, 我们去写js代码来继续完成我们的游戏.

完成一个3D相机功能

==========

相机在3D开发中必不可少, 使用相机功能不仅能查看3D世界模型, 同时也能实现很多实时的炫酷功能.

一个3d相机需要哪些功能?

最简单的, 上下左右能够360度无死角观察地图.同时需要拉近拉远视距.

通过鼠标交互

鼠标左右移动可以旋转查看地图; 鼠标上下移动可以观察上下地图; 鼠标滚轮可以拉近拉远视距.

✅ 1. 监听鼠标事件

首先, 我们需要通过监听鼠标事件来记录鼠标位置, 从而判断相机上下左右查看.

/** 鼠标上次位置 */

var lastX = 0, lastY = 0;

/** 控制一次滑动 */

var isDown = false;

/** 监听鼠标按下 */

document.addEventListener(“mousedown”, (e) => {

lastX = e.clientX;

lastY = e.clientY;

isDown = true;

});

/** 监听鼠标移动 */

document.addEventListener(“mousemove”, (e) => {

if (!isDown) return;

let _offsetX = e.clientX - lastX;

let _offsetY = e.clientY - lastY;

lastX = e.clientX;

lastY = e.clientY;

//判断方向

var dirH = 1, dirV = 1;

if (_offsetX < 0) {

dirH = -1;

}

if (_offsetY > 0) {

dirV = -1;

}

});

document.addEventListener(“mouseup”, (e) => {

isDown = false;

});

复制代码

✅ 2. 判断相机上下左右

使用perspective-origin来设置相机的上下视线.

使用transform来旋转Z轴查看左右方向上的360度.

/** 监听鼠标移动 */

document.addEventListener(“mousemove”, (e) => {

if (!isDown) return;

let _offsetX = e.clientX - lastX;

let _offsetY = e.clientY - lastY;

lastX = e.clientX;

lastY = e.clientY;

var bg_style = document.defaultView.getComputedStyle(_ground, null).transform;

var camera_style = document.defaultView.getComputedStyle(_camera, null).perspectiveOrigin;

var matrix4 = new Matrix4();

var _cy = +camera_style.split(’ ')[1].split(‘px’)[0];

var str = bg_style.split(“matrix3d(”)[1].split(“)”)[0].split(“,”);

var oldMartrix4 = str.map((item) => +item);

var dirH = 1, dirV = 1;

if (_offsetX < 0) {

dirH = -1;

}

if (_offsetY > 0) {

dirV = -1;

}

//每次移动旋转角度

var angleZ = 2 * dirH;

var newMartri4 = matrix4.set(Math.cos(angleZ * Math.PI / 180), -Math.sin(angleZ * Math.PI / 180), 0, 0, Math.sin(angleZ * Math.PI / 180), Math.cos(angleZ * Math.PI / 180), 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);

var new_mar = null;

if (Math.abs(_offsetX) > Math.abs(_offsetY)) {

new_mar = matrix4.multiplyMatrices(oldMartrix4, newMartri4);

} else {

_camera.style.perspectiveOrigin = 500px ${_cy + 10 * dirV}px;

}

new_mar && (_ground.style.transform = matrix3d(${new_mar.join(',')}));

});

复制代码

这里使用了矩阵的方法来旋转Z轴, 矩阵类Matrix4是我临时写的一个方法类, 就俩方法, 一个设置二维数组matrix4.set, 一个矩阵相乘matrix4.multiplyMatrices.

文末的源码地址中有, 这里就不再赘述了.

✅ 3. 监听滚轮拉近拉远距离

这里就是根据perspective来设置视距.

//监听滚轮

document.addEventListener(‘mousewheel’, (e) => {

var per = document.defaultView.getComputedStyle(_camera, null).perspective;

let newper = (+per.split(“px”)[0] + Math.floor(e.deltaY / 10)) + “px”;

_camera.style.perspective = newper

}, false);

复制代码

注意:

perspective-origin属性只有X、Y两个值, 做不到和u3D一样的相机.

我这里取巧使用了对地平线的旋转, 从而达到一样的效果.

滚轮拉近拉远视距有点别扭, 和3D引擎区别还是很大.

完成之后可以看到如下的场景, 已经可以随时观察我们的地图了.

index1.gif

这样子, 一个3D相机就完成, 大家有兴趣的可以自己下去写一下, 还是很有意思的.

绘制迷宫棋盘

======

绘制格子地图最简单了, 我这里使用一个15*15的数组.

0」代表可以通过的路, 「1」代表障碍物.

var grid = [

0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0,

0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 1, 0, 1, 0,

1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1,

0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1,

0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0,

0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0,

0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0,

1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0,

1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0,

0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0,

1, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 1,

0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0,

1, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0,

1, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0,

0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0

];

复制代码

然后我们去遍历这个数组, 得到地图.

写一个方法去创建地图格子, 同时返回格子数组和节点数组.

这里的block是在html中创建的一个预制体, 他是一个正方体.

然后通过克隆节点的方式添加进棋盘中.

/** 棋盘 */

function pan() {

const con = document.getElementsByClassName(“pan”)[0];

const block = document.getElementsByClassName(“block”)[0];

let elArr = [];

grid.forEach((item, index) => {

let r = Math.floor(index / 15);

let c = index % 15;

const gezi = document.createElement(“div”);

gezi.classList = “pan-item”

// gezi.innerHTML = ${r},${c}

con.appendChild(gezi);

var newBlock = block.cloneNode(true);

//障碍物

if (item == 1) {

gezi.appendChild(newBlock);

blockArr.push(c + “-” + r);

}

elArr.push(gezi);

});

const panArr = arrTrans(15, grid);

return { elArr, panArr };

}

const panData = pan();

复制代码

可以看到, 我们的界面已经变成了这样.

WechatIMG310.png

接下来, 我们需要去控制玩家移动了.

控制玩家移动

======

通过上下左右w s a d键来控制玩家移动.

使用transform来移动和旋转玩家盒子.

✅ 监听键盘事件

通过监听键盘事件onkeydown来判断key值的上下左右.

document.onkeydown = function (e) {

/** 移动物体 */

move(e.key);

}

复制代码

✅ 进行位移

在位移中, 使用translate来平移, Z轴始终正对我们的相机, 所以我们只需要移动X轴和Y轴.

声明一个变量记录当前位置.

同时需要记录上次变换的transform的值, 这里我们就不继续矩阵变换了.

/** 当前位置 */

var position = { x: 0, y: 0 };

/** 记录上次变换值 */

var lastTransform = {

translateX: ‘0px’,

translateY: ‘0px’,

translateZ: ‘25px’,

rotateX: ‘0deg’,

rotateY: ‘0deg’,

rotateZ: ‘0deg’

};

复制代码

每一个格子都可以看成是二维数组的下标构成, 每次我们移动一个格子的距离.

switch (key) {

case ‘w’:

position.y++;

lastTransform.translateY = position.y * 50 + ‘px’;

break;

case ‘s’:

position.y–;

lastTransform.translateY = position.y * 50 + ‘px’;

break;

case ‘a’:

position.x++;

lastTransform.translateX = position.x * 50 + ‘px’;

break;

case ‘d’:

position.x–;

lastTransform.translateX = position.x * 50 + ‘px’;

break;

}

//赋值样式

for (let item in lastTransform) {

strTransfrom += item + ‘(’ + lastTransform[item] + ') ';

}

target.style.transform = strTransfrom;

复制代码

到这里, 我们的玩家盒子已经可以移动了.

注意

在css3D中的平移可以看成是世界坐标.

所以我们只需要关心X、Y轴. 而不需要去移动Z轴. 即使我们进行了旋转.

✅ 在移动的过程中进行旋转

在CSS3D中, 3D旋转和其他3D引擎中不一样, 一般的诸如u3D、threejs中, 在每次旋转完成之后都会重新校对成世界坐标, 相对来说 就很好计算绕什么轴旋转多少度.

然而, 笔者也低估了CSS3D的旋转.

我以为上下左右滚动一个正方体很简单. 事实并非如此.

CSS3D的旋转涉及到四元数和万向锁.

比如我们旋转我们的玩家盒子. 如图所示:

首先, 第一个格子(0,0)向上绕X轴旋转90度, 就可以到达(1.0); 向左绕Y轴旋转90度, 可以到达(0,1); 那我们是不是就可以得到规律如下:

WechatIMG312.png

如图中所示, 单纯的向上下, 向左右绕轴旋转没有问题, 但是要旋转到红色的格子, 两种不同走法, 到红色的格子之后旋转就会出现两种可能. 从而导致旋转出错.

同时这个规律虽然难寻, 但是可以写出来, 最重要的是, 按照这个规律来旋转CSS3D中的盒子, 是不对的

那有人就说了, 这不说的屁话吗?

经过笔者实验, 倒是发现了一些规律. 我们继续按照这个规律往下走.

  • 旋转X轴的时候, 同时看当前Z轴的度数, Z轴为90度的奇数倍, 旋转Y轴, 否则旋转X轴.

  • 旋转Y轴的时候, 同时看当前Z轴的度数, Z轴为90度的奇数倍, 旋转X轴, 否则旋转Z轴.

  • 旋转Z轴的时候, 继续旋转Z轴

这样子我们的旋转方向就搞定了.

if (nextRotateDir[0] == “X”) {

if (Math.floor(Math.abs(lastRotate.lastRotateZ) / 90) % 2 == 1) {

lastTransform[rotateY] = (lastRotate[lastRotateY] + 90 * dir) + ‘deg’;

} else {

lastTransform[rotateX] = (lastRotate[lastRotateX] - 90 * dir) + ‘deg’;

}

}

if (nextRotateDir[0] == “Y”) {

if (Math.floor(Math.abs(Math.abs(lastRotate.lastRotateZ)) / 90) % 2 == 1) {

lastTransform[rotateX] = (lastRotate[lastRotateX] + 90 * dir) + ‘deg’;

} else {

lastTransform[rotateZ] = (lastRotate[lastRotateZ] + 90 * dir) + ‘deg’;

}

}

if (nextRotateDir[0] == “Z”) {

lastTransform[rotate${nextRotateDir[0]}] = (lastRotate[lastRotate${nextRotateDir[0]}] - 90 * dir) + ‘deg’;

}

复制代码

然而, 这还没有完, 这种方式的旋转还有个坑, 就是我不知道该旋转90度还是-90度了.

这里并不是简单的上下左右去加减.

旋转方向对了, 旋转角度不知该如何计算了.

具体代码可以查看源码[3].

彩蛋时间

⚠️⚠️⚠️ 同时这里会伴随着「万向锁」的出现, 即是Z轴与X轴重合了. 哈哈哈哈~

⚠️⚠️⚠️ 这里笔者还没有解决, 也希望万能的网友能够出言帮忙~

⚠️⚠️⚠️ 笔者后续解决了会更新的. 哈哈哈哈, 大坑.

好了, 这里问题不影响我们的项目. 我们继续讲如何找到最短路径并给出提示.

最短路径的计算

=======

在迷宫中, 从一个点到另一个点的最短路径怎么计算呢? 这里笔者使用的是广度优先遍历(BFS)算法来计算最短路径.

我们来思考:

  1. 二维数组中找最短路径

  2. 每一格的最短路径只有上下左右相邻的四格

  3. 那么只要递归寻找每一格的最短距离直至找到终点

这里我们需要使用「队列」先进先出的特点.

我们先来看一张图:

WechatIMG313.png

很清晰的可以得到最短路径.

注意

使用两个长度为4的数组表示上下左右相邻的格子需要相加的下标偏移量.

每次入队之前需要判断是否已经入队了.

每次出队时需要判断是否是终点.

需要记录当前入队的目标的父节点, 方便获取到最短路径.

我们来看下代码:

//春初路径

var stack = [];

/**

* BFS 实现寻路

* @param {*} grid

* @param {*} start {x: 0,y: 0}

* @param {*} end {x: 3,y: 3}

*/

function getShortPath(grid, start, end, a) {

let maxL_x = grid.length;

let maxL_y = grid[0].length;

let queue = new Queue();

//最短步数

let step = 0;

//上左下右

let dx = [1, 0, -1, 0];

let dy = [0, 1, 0, -1];

//加入第一个元素

queue.enqueue(start);

//存储一个一样的用来排查是否遍历过

let mem = new Array(maxL_x);

for (let n = 0; n < maxL_x; n++) {

mem[n] = new Array(maxL_y);

mem[n].fill(100);

}

while (!queue.isEmpty()) {

let p = [];

for (let i = queue.size(); i > 0; i–) {

let preTraget = queue.dequeue();

p.push(preTraget);

//找到目标

if (preTraget.x == end.x && preTraget.y == end.y) {

stack.push§;

return step;

}

//遍历四个相邻格子

for (let j = 0; j < 4; j++) {

let nextX = preTraget.x + dx[j];

let nextY = preTraget.y + dy[j];

if (nextX < maxL_x && nextX >= 0 && nextY < maxL_y && nextY >= 0) {

let nextTraget = { x: nextX, y: nextY };

if (grid[nextX][nextY] == a && a < mem[nextX][nextY]) {

queue.enqueue({ …nextTraget, f: { x: preTraget.x, y: preTraget.y } });

mem[nextX][nextY] = a;

}

}

}

}

stack.push§;

step++;

}

}

/* 找出一条最短路径**/

function recall(end) {

let path = [];

let front = { x: end.x, y: end.y };

while (stack.length) {

let item = stack.pop();

for (let i = 0; i < item.length; i++) {

if (!item[i].f) break;

if (item[i].x == front.x && item[i].y == front.y) {

path.push({ x: item[i].x, y: item[i].y });

front.x = item[i].f.x;

front.y = item[i].f.y;

break;

}

}

}

return path;

}

复制代码

这样子我们就可以找到一条最短路径并得到最短的步数.

然后我们继续去遍历我们的原数组(即棋盘原数组).

点击提示点亮路径.

var step = getShortPath(panArr, { x: 0, y: 0 }, { x: 14, y: 14 }, 0);

console.log(“最短距离----”, step);

_perstep.innerHTML = 请在<span>${step}</span>步内走到终点;

var path = recall({ x: 14, y: 14 });

console.log(“路径—”, path);

/** 提示 */

var tipCount = 0;

_tip.addEventListener(“click”, () => {

console.log(“9999”, tipCount)

elArr.forEach((item, index) => {

let r = Math.floor(index / 15);

let c = index % 15;

path.forEach((_item, i) => {

if (_item.x == r && _item.y == c) {

// console.log(“ooo”,_item)

if (tipCount % 2 == 0)

item.classList = “pan-item pan-path”;

else

item.classList = “pan-item”;

}

})

});

tipCount++;

});

复制代码

这样子, 我们可以得到如图的提示:

最后

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点!不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!

如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

_tip.addEventListener(“click”, () => {

console.log(“9999”, tipCount)

elArr.forEach((item, index) => {

let r = Math.floor(index / 15);

let c = index % 15;

path.forEach((_item, i) => {

if (_item.x == r && _item.y == c) {

// console.log(“ooo”,_item)

if (tipCount % 2 == 0)

item.classList = “pan-item pan-path”;

else

item.classList = “pan-item”;

}

})

});

tipCount++;

});

复制代码

这样子, 我们可以得到如图的提示:

最后

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

[外链图片转存中…(img-86aloySu-1715889864581)]

[外链图片转存中…(img-K97sgEuT-1715889864582)]

[外链图片转存中…(img-Yg0N3nSN-1715889864582)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点!不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!

如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

  • 11
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
案例一:电影数据分析 数据来源:IMDb(互联网电影数据库)数据集 分析目的:通过对电影数据的分析,探究影响电影票房的因素,以及电影市场的趋势和变化。 分析内容: 1. 电影票房排行榜:分析票房最高的电影类型、上映时间、导演、演员等因素,探究影响票房的因素。 2. 电影类型分析:分析不同类型电影的票房、评分、制作成本等数据,比较各类型电影的表现和市场需求。 3. 电影市场趋势:分析电影市场的发展趋势和变化,例如近年来国产电影的崛起、好莱坞巨制的票房成绩等。 4. 演员和导演分析:分析不同演员和导演的电影作品表现,探究他们对电影的影响力和市场价值。 案例二:交通事故数据分析 数据来源:美国国家公路交通安全管理局(NHTSA)数据集 分析目的:通过对交通事故数据的分析,探究交通安全问题的原因和解决方法,提高交通安全意识。 分析内容: 1. 事故类型分析:分析不同类型的事故发生率和伤亡情况,探究事故类型的原因和防范措施。 2. 驾驶员分析:分析不同年龄、性别、驾照类型的驾驶员在事故中的表现和责任,探究驾驶员素质和驾驶行为对交通安全的影响。 3. 路况分析:分析不同路段、时间、天气等因素对交通事故的影响,探究交通设施和规划对交通安全的影响。 4. 预测模型建立:通过建立交通事故的预测模型,探究交通事故的趋势和规律,为交通安全管理提供科学依据。 案例三:销售数据分析 数据来源:企业销售数据集 分析目的:通过对销售数据的分析,探究销售业绩的原因和提高销售效率的方法,为企业制定营销策略提供依据。 分析内容: 1. 销售趋势分析:分析销售数据的趋势和变化,探究销售业绩的原因和趋势。 2. 产品分析:分析不同产品的销售情况和市场需求,探究产品的优劣势和市场价值。 3. 客户分析:分析顾客的购买行为和偏好,探究客户需求和购买动机,为企业制定营销策略提供依据。 4. 促销策略分析:分析不同促销策略的效果和成本,探究促销策略对销售业绩的影响和价值。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值