编写2048小游戏

Posted by wyj on February 25, 2020 / Edited on March 20, 2021

今天是没有训练的,所以昨天晚上和今天上午除了以撒之外,一直在写这个2048。

我曾经写过一个控制台版本,是在我初中的最后几天。那天上午的训练题我全都不会做,就无聊地写了一个2048自己玩。我那时还是一个Windows用户,所以使用的全是Windows的API以及conio.h(现在我学了ncurses,但是一样很垃圾)。当时我曾经把它放进过MyProg,然而那时我不是linux用户也不会用git,所以高一时由于U盘坏了就丢失了。

我的计划是按照两年前的逻辑写模拟部分,UI就参考原版2048,这样最不用动脑子。

模拟部分

由于我已经很久没有写题了,熟练度为0,模拟的逻辑写错了好几次。为了好写,我不可能把四个方向的移动各写一遍,而是先旋转到一个统一的方向,合并,然后再旋转回去。我两年前就是这么写的。说起来简单,其实细节挺多,首先是判断有没有死局,第二是循环上界的问题,有的地方循环到3就溢出了,有的地方循环到2又不够(我是从0到3编号的)。

UI部分

用一个居中<div>框住所有的游戏部件,左上角有一个<p>,<p>里面有<span>是成绩,右上角有一个<button>用来重开。下面有一个<div>里面放着$4\times 4$的格子<canvas>,使用margin-top与上方控件保持距离。

这个UI不全是CSS,因为格子我用了<canvas>画。因为canvas实在是太符合我的使用习惯了,从Free Pascal的graph单元,Lazarus的TGraph类,到windows.h里面的wingdi,再到Qt的QPainter,都是同一套函数,可以用bash的展开标记为{Set,Fill,Draw..}{Rect,Pixel,Text..}canvas也很类似。

4x4的格子

我觉得$450=(4+1)\times 10+4\times 100$是一个不错的排布,都是整数,就把<canvas>画成了$450\times 450$。

唯一我不太清楚的是如何得到字体的高度(宽度是width),用Chrome看了一下ctx.measureText()的成员,找到了一个actualBoundingBoxAscent,貌似是高度,我就试着把它当成高度用,貌似也没有问题。为什么不叫height呢?不懂这套命名逻辑。还有一个有趣的问题是指定字体格式的字符串里,bold必须加在??px的前面,不能调换位置。最后我用的字体是bold arial,这个貌似和原版最像。

框架已经完全摆好了,就差颜色了。我打算直接使用原版的颜色。然而我没听说过什么可以直接取屏幕像素点rgb值的方法。曾经在windows下我造过这个轮子,再造一个太烦了,不值得。

于是按照老套路,先找到我那几张从$2$一直到$16384$都有的截图,convert成ppm,写一个c++程序支持输入坐标输出该点颜色。大概是这样的:

#include<bits/stdc++.h>
using namespace std;
using ll=long long;
int m,n,x,y;
unsigned char c[2010][2010][3];
int main(){
	auto F=fopen("1.ppm","rb");
	fscanf(F,"P6\n%d %d\n255\n",&m,&n);
	for(int i=0;i<n;i++)
		for(int j=0;j<m;j++)
			fread(c[i]+j,1,3,F);
	while(~scanf("%d%d",&x,&y))
		printf("#%02x%02x%02x\n",c[x][y][0],c[x][y][1],c[x][y][2]);
	return 0;
}

再开一个KolourPaint支持读取鼠标的坐标,把读到的坐标输入到程序里得出rgb,最后用gedit的”颜色拾取器”插件检验成果。

Update 2020-05-23:完全不用这么麻烦,今天看了baby WOGUE的视频我才发现Gtk3自带屏幕颜色拾取器。使用方法:先启用gedit的”颜色拾取器”插件,再在gedit的“工具”菜单里选择”Pick Color…“一项。点击左下角的加号,会出来一个新的窗口,再在新窗口里点左上角的注射器符号,点击屏幕上你想拾取颜色的位置。就可以获得颜色的RGB了。

上方文字

不得不说html默认的按钮样式真丑,一点也不扁平化。我把边框去掉了,改了下字体和大小,才勉强变得可看了一点。

我试验了很久才搞懂如何让一个button和一个p左右并排显示,原来一个设成float:left一个设成float:right就行了,亏我还学了半天的绝对定位相对定位。

一些杂碎的问题

关于出$2$/出$4$的概率,我随手定了一个80%出$2$。这个只是遵循我两年前的传统,没有什么理由。

我打算让网页加载时自动滚动到游戏部分,隐藏标题栏,在这里找到了解决方案。

原计划是在游戏结束时alert一个窗口,然后自动重开的,然而一是alert出的窗口太丑了,二是不知道为什么alert出窗口时我对html的修改尚未执行,肯定又是js假多线程的锅。于是我改变了计划,改成了在左上角显示Game over,冻结游戏界面直到重开,这个逻辑是和原版一样的。

然而又遇到了新问题,我不会在不影响子节点的前提下改变一个节点的文本。在这里我找到了简单粗暴的解决方案:先用临时变量保存下来它的子节点,然后把它删了重建一个节点。虽然粗暴了点,但是总归比把子节点硬编码在js里然后每次都重写一遍子节点内容好多了。

用上下左右控制时网页会乱动,用这里的方案解决了。

\[\Large{\textrm{更新的分割线}}\]

更新:动画效果

我两年前写的控制台版本和此网站上的初始版本有一个共同的缺陷:没有动画。我曾经装过gnome-2048,这个版本只有新数字和合并结果的强调效果,没有数字移动的动画,我也不太习惯。这样我经常不能觉察到数字的移动与新数字的出现,从而会操作失误,并且显示也很不自然。于是晚上我打算添加动画效果。

我在网上搜了一圈canvas怎么做动画。大多数的搜索结果都是使用requestAnimationFrame,我首先使用这个写了一份,效果很失败。因为这个函数是异步的,除非全部包在Promise里,我是没有办法在主循环里等待它返回的,而全部使用Promise让代码过于冗长。而且通过递归来完成一个循环就能做到的事,我觉得是真的丑。

于是我使用了传统方法,把setTimeout包在Promise里用来模仿其他语言中sleep的行为。然后加上一些asyncawait就可以完成任务。

首先是移动,我们要额外开一个数组记录每一个位置的终点,并且在旋转和合并的时候维护它,绘制时就线性地移动。然后是强调合并数字,再开一个数组记录哪些格子是合并的结果,绘制时只要把格子先放大几像素再缩小回去就行了。至于强调新数字,由于这个和移动后绘制是不能同时完成的,实际操作时会有些卡,我就没加。现在已经随着Bug修复一起加上了。

说起来简单,实际上这个是要加上几百行代码才能完整实现的功能。

更新:本地存储

原版的记忆功能是真的好评,而且可以多开从而起到“可持久化”的功效,这比撤销操作nb多了。我搜了一下,这个比较简单,只要使用localStorage即可。有一个奇怪的限制是这个对象里面只能放字符串,连数字都不行,不知道为什么。所以把数据放进去之前需要JSON.stringify,提取时用JSON.parse

另外今天又去搜索了一下真正的出$4$概率,应该是$\frac{1}{10}$,我顺手修改了一下。

更新:兼容性

我在Firefox和windows中的Edge里面尝试打开游戏,却发现<canvas>中的字体无法显示。调试了一下,发现是因为actualBoundingBoxAscent这个属性只在Chrome里面存在。又去搜了一圈解决方案,没有。只有一个按照字体大小乘上常数确定字体高度的方案,虽然丑陋,但是挺实用的。Chrome里面字体大小是$60px$时高度返回$45$,所以我直接乘上$0.75$。别的浏览器现在也可以正常显示了。

更新:触摸屏支持

为了在手机上也能愉快的颓废,我增加了手势支持。使用了现成的轮子Hammer.js,只要增加$10$行代码就可以识别swipe手势了。唯一需要注意的是要先设置hammer.get('swipe').set({direction: Hammer.DIRECTION_ALL});,这是教程里貌似没有讲的。

然而这个更新我是无法在电脑上测试效果的。使用jekyll只能在本机查看网站(ip是127.0.0.1而不是0.0.0.0)。我不会用最常见的工具iptables做端口转发,所以只好在生成的_site/目录下运行python3 -m http.server,然后手机上访问局域网网址测试。

更新:Bug修复

Bug #1:对于$4096$以上的数字显示成白方块。
Fix:在animate()函数中把数字大小对$12$取Math.min

Bug #2:动画中数字不对。
Fix:把一个a数组打成了b数组。

Bug #3:多个动画一起播放,出现混乱。
Fix:这个特别烦。首先是添加时间戳,在动画每帧播放结束后判断一下当前动画是否为最新的,如果有更新的动画开始播放了就直接退出。我之前是每次移动之后播放一次动画,然而下一次按键时如果动画还没有放完,生成新数字就不会执行,这个问题很大。所以我把移动完成之后的环境缓存下来,做完一切操作最后才放动画,这样动画中断也不会有任何影响了。这样也可以同时模拟新方块的生成了。

更新:移动设备兼容性

今天Google Search Console提示我的网页对于移动设备支持不够良好,所以我花了点时间改了下CSS增强兼容性。首先需要把<header>改短一点,然而header的高度是由内部的元素高度自动确定的,所以我要把标题下方的padding改小一点,header就可以缩短了。其次,需要让<canvas>显示不超出屏幕范围。这个简单,只需要加个width:100%的css就好了。并且这些修改应该只对移动端生效。我翻了下自己博客的CSS源代码,了解到应该使用这样的代码块:

@media only screen and (max-width: 767px){
	...
}