简介
使用 HTML + CSS + JavaScript 制作了 2048 小游戏,并尽可能地还原了动画效果。(虽然仍然有一些奇怪的小 bug)
源代码及预览见文末。
CSS:遇到的问题
1.伪元素设置 position:absolute 定位错误
#问题描述:
在添加分数栏的文字时使用的是伪元素,想通过给 score 的伪元素设置 position:absolute 来相对 score 定位,但是定位一直不正确。
#score::after {
content: "SCORE";
position: absolute;
left: 50%;
transform: translateX(-50%);
color: #eee4da;
line-height: 3rem;
font-size: 0.5rem;
font-weight: bold;
}
#解决方案:
给 score 设置 position:relative,伪元素应当是添加在
<score> 一些 text 节点or Element 节点…</score> 的内部,作为选中元素的子节点。其中,
::before 是作为选中元素的第一个子元素,
::after 是作为选中元素的最后一个子元素。
2.使用 grid 布局绘制游戏界面
- 对父容器使用 grid-template-rows / grid-template-columns 进行布局行列的划分,其中可以使用 repeat(4,1fr) 来均分为 4 等份。
使用 fr 而不是具体值可以相对地按比例分配剩余空间。
其中,剩余空间 = 父容器的 width / height - 间隔大小 gap - 向父容器中添加16个子元素。
- 使用 row-gap / column-gap 设置行列之间的间隙,将 16 个格子独立出来。
- 对父容器设置 box-sizing:border-box,
使父容器的 offsetWidth / offsetHeight( = border + padding + content ) = 所设置的 width / height。
并设置 padding 使得边缘部分也有间隔。
#game {
position:relative;
/* 设置 grid 属性*/
display: grid;
grid-template-rows: repeat(4, 1fr);
grid-template-columns: repeat(4, 1fr);
row-gap: 10px;
column-gap: 10px;
width: 450px;
height: 450px;
/* 设置 box-sizing */
box-sizing: border-box;
padding: 10px;
background-color: #bbada0;
border-radius: 5px;
margin: 20px 0;
overflow: hidden;
color: #fff;
}
3.相对长度单位 em 和 rem
#问题描述:
使用 em 时有时长度对不上自己的预期。
#解决方案:
查阅 MDN 可知:
em
- 在 font-size 中使用是相对于父元素的字体大小
- 在其他属性中使用是相对于自身的字体大小
rem
而 rem 则始终是相对于根元素的字体大小。
4. :not 伪类选择器
使用 .item:not(.item[data-value = ‘0’]) 来选择data-value 不为 ‘0’ 的所有.item元素。
5. transition 添加动画效果
#缩放:从中央逐渐放大至设定大小
在 CSS 中设置好元素最终的大小属性,
然后设置 transform:scale(0) 缩小为0,
设置 transition 的 timing-function 为 ease-in。
在 JavaScript 中将 transform 修改为 scale(1) 恢复原来的大小,
就可以激活 transition 的动画效果。
并且无需使用 JavaScript 跟踪改变元素的 left / top,将 left / top 设置为最终的 left / top 即可。
因为元素变形原点 transform-origin 默认值为 center。
▲具体可见 MDN transform-origin
transform:scale(0);
transition:transform 200ms ease-in;
#弹出:从中央逐渐放大至超过设定大小,再缩小回设定大小
同样使用 transform:scale(0) 作为初始值,
同样在 JavaScript 中控制 transform 改变。
不同于上一个效果的是 超过设定大小 的效果,
可以使用自定义的 timing-function 来实现:
transform:scale(0);
transition:transform 140ms cubic-bezier(0,.2,0,1.5);
其中 贝塞尔曲线 cubic-bezier(0,0.2,0,1.5) 图示如下。
▲非常好的贝塞尔曲线网站
#位移
本例中实现方块移动的动画,
是通过在 JavaScript 中创建一个要移动元素 的 替身元素,
然后修改替身元素的 left / top 来实现移动。
因此,可以对 left / top 设置 transition。
transition:left 100ms ease-in,top 100ms ease-in;
JavaScript 遇到的问题
1.JavaScript 设置改变元素样式,CSS transition 不生效
#问题描述
想生成一个位移的动画,是通过 JavaScript 获取被位移元素的属性,并在此元素的位置上生成一个替身元素(即设置其 left / top 使其与被位移元素重叠),然后操纵改变它的 left / top 使其 CSS 中的 transition 生效,但是 transition 始终不生效。
#解决方案
原因是因为在 JavaScript 同一个函数中两次修改元素的 style,
两次修改是发生在同一任务中的,
而当 JavaScript 主线程执行任务时,浏览器渲染线程是挂起的,
当任务完成时才发生 DOM 修改,因此浏览器只会进行一次渲染,即直接修改为最后的 left / top 值。
- 可以通过 setTimeout(()=>修改样式,0)强制将第二次修改滞后为另一次任务,使浏览器进行重绘(重排),触发 transition 动画。
- 可以在第一次修改过后访问该元素 布局 有关的属性,如 offsetWidth / getBoundingClientRect(),强制更新 style。
此处 setTimeout 虽然为 0ms,但实际在浏览器中最小为 4ms。
▲关于css中transition, js设置两个值有时不能显示动画效果?
▲JavaScript 的单线程和异步
▲StackOverflow 上关于此问题的详细说明
2.监听页面加载完成,进行初始化
使用 document.addEventListener(’'DOMContentLoaded",callback)
必须使用 addEventListener捕获。
DOMContentLoaded :浏览器已完全加载 HTML,并构建了 DOM 树,但像 和样式表之类的外部资源可能尚未加载完成。
3.简化四个方向的方块移动
本例中通过二维数组来存储游戏方块情况,
通过二维数组的值存储方块的数值。
因此可以通过提供一个包含遍历顺序的下标的对象 traversal 来完成遍历:
比如,向右移动时,y 轴(column)应从右向左检查空位,使方块按顺序右移,而 x 轴则无所谓,可以按照从上至下的遍历顺序,因此提供的 traversal 为:
{
x:[0,1,2,3],
y:[3,2,1,0]
}
向下移动时, x 轴(row)应从下向上检查空位:
{
x:[3,2,1,0],
y:[0,1,2,3]
}
而其余两个方向可以按照从上至下,从左至右的顺序。
因此可以提供一个初始的traversal = { x:[0,1,2,3],y:[0,1,2,3]}
然后如果方向是向右,则翻转 traversal.y.reverse(),
如果是向下,则翻转 traversal.x.reverse()
然后使用
traversal.x.forEach(x =>{
traversal.y.forEach(y => {
}
}
进行遍历。
#移动思路为:
1 . 按照遍历顺序进行遍历,如果遍历到的方块值(即二维数组对应元素的值)不为 0,则检查其移动方向上是否有空位,找到离该方块最远可以到达的一个位置。
2 . 检查最远可达位置 在 移动方向上的下一格(如果有)的值是否与当前方块值相同,即是否可以合并,如果可以,则最远可达位置更新为下一格。
3 . 如果最远可达位置与当前位置相同,不作修改。
4 . 如果不同,则删除当前的方块,并新增一个方块在最远可达位置,如果发生了合并,则注意更新方块的值,并注意将此格进行标记,因为一格在一次移动中最多合并一次,防止后续被再次合并。
#代码如下:
由于遍历比较耗时,因此使用 promise 包装此函数进行阻塞,返回值时相当于resolve()。
async moveTile(d, traversal) {
// 检查是否发生更改,即移动过后是否要创建一个新的方块
let changed = false;
// 累计本次移动的分数
let score = 0;
// 调整遍历顺序
// 向下
if (Game.dir[d][0] == 1) traversal.x = traversal.x.reverse();
// 向上
if (Game.dir[d][1] == 1) traversal.y = traversal.y.reverse();
// 保存上一次被合并的方块,防止二次合并
let lastChangedItem = null;
traversal.x.forEach((i) => {
traversal.y.forEach((j) => {
let val = this.tile[i][j];
if (val != 0) {
let cur = { x: i, y: j, val: val };
// 找最远可达位置
let finalPos = this.findFinalPos(d, cur);
// 最远可达位置的下一格
let next = finalPos.next;
// 保存最终的位置
let newTile;
// 合并的情况
if (
next.x >= 0 &&
next.x < 4 &&
next.y >= 0 &&
next.y < 4 &&
val == this.tile[next.x][next.y] &&
(!lastChangedItem ||
next.x != lastChangedItem.x ||
next.y != lastChangedItem.y)
) {
score += val * 2;
newTile = { x: next.x, y: next.y, val: val * 2 };
lastChangedItem = { x: next.x, y: next.y };
} else {
newTile = { x: finalPos.x, y: finalPos.y, val: val };
}
if (!changed && (newTile.x != i || newTile.y != j)) {
changed = true;
}
// 无事发生,不修改,继续遍历
if (newTile.x == i && newTile.y == j) return;
// 更新数组信息
this.tile[i][j] = 0;
this.tile[newTile.x][newTile.y] = newTile.val;
// 移动方块
this.move(cur, newTile);
}
});
});
// 更新分数
if (score) this.updateScore(score);
return changed;
}