Day29. 虽然今年是2021,但我们要做2048(3)

来到2048的最後一天!看看这麽多的删除线!虽然可能我们不一定能清光我们购物网站上的愿望清单,但是今天,让我们一起清空这里列的代办清单。

  • 基本容器宣告与画面初始
  • 依据滑动改变重力
  • 随机初始圆形
  • 滑行後生出新的图形但是短暂 Sleep
  • 滑行後唤醒全部的 Sleep 图形
  • 碰撞时判定合并
  • 合并计分
  • 自适应画面大小

另外我们会加上这些元素,来让我们的整体游戏更完满。

  • 结束测定
  • 画面重置
  • 文字附加

今天的Demo
今天的Demo原始码
https://ithelp.ithome.com.tw/upload/images/20211014/20142057DNHieN7ydK.png

我们稍微依照事件发生的顺序,排序一下需求,下面我们就照着这个顺序来处理:

  • 自适应画面大小
  • 文字附加
  • 合并计分
  • 结束测定
  • 画面重置

(我把 Day29 的字缩小啦!让成品稍微好看一点,笔者的小私心XD )

自适应画面大小

这部份如果前几天的 Demo 各位有用手机试过就会知道,不滑不要紧,一滑,整个画面都滑动,所以我们要做些处理来解决这个问题。

首先是要依屏幕大小来决定 Canvas的长宽,我们的逻辑是如果小於 600 就依屏幕等宽,大於 600 则拿 600 当 Canvas 的长宽。

// main.js
.
.
const canvasWidth = screen.width < 600 ? screen.width : 600 ;
const canvasHeight = screen.width < 600 ? screen.width : 600 ;

接着是处理我们 html 档案的最外层,body,我们加上 style 中的 overflow: hidden 和 height: 100%让页面不显示卷动条,也不能卷动,这样就不影响我们滑动的体验了!

body{
    background-color: #FEFAE0;
    display: flex;
    justify-content: center;
    overflow: hidden;
		height: 100%;
}

其他 css 跟 layout 排版笔者这边不多提,毕竟不是本文主体,对 style 有疑问可以直接 google, css 的大神会告诉你该怎麽好好写的。

文字附加

https://ithelp.ithome.com.tw/upload/images/20211014/20142057G7csbIKJGa.png
文字附加指的是在球上追加文字显示,让玩家知道他现在每颗球是多少,这个对玩家的游戏体验是一个很大的帮助(笔者个人观感)。

尽管先前我们说过 Matter.js 没有内建提供这种在物体上追加绘制文字的功能,但 google 是个好东西,我们可以看到曾经有使用这在这个 Matter.js 的 repo issue 问过:

Is there anyway to render text in Matter.js?

答案是没有,但是集思广益下,下面有人提出了一个写法,是直接在 canvas 上即时依据传入内容用原生 canvas 的函式来做绘制文字,所以我们参考这个方法,就能写出下面这个绘制文字的函式:

// renderText.js
function renderText()
{
    var ctx = document.getElementsByTagName("canvas")[0].getContext("2d");
    if(!ctx) return;
    for(var elementId in engine.world.bodies)
    {
        var targetBody = engine.world.bodies[elementId];
        if(!Number(targetBody.label)) continue;
        if(targetBody.render.text)
        {
            var fontsize = 20;
						var fontfamily = "Arial";
            var color = "#EEEEEE";
            fontsize = targetBody.circleRadius;
                
            ctx.textBaseline="middle";
            ctx.textAlign="center";
            ctx.fillStyle=color;
						ctx.font = fontsize + 'px '+fontfamily;
            ctx.fillText(targetBody.render.text,targetBody.position.x,targetBody.position.y);
        }
    }   
}

函式里面做的事情是,当这个函式被呼叫,我们会去取用 ctx,也就是我们 canvas 的操作口。接着遍历世界中的所有物体,当 label 属性是数字(我们定义创建的圆形才会是数字,其他系统预设都会是文字),我们才会对该图形的 .text 属性进行取用,抓取该图形位置,并依照圆形的半径来决定文字大小,最後就是直接於 canvas 的对应位置上写字。

我们所有创建球体的部分都要因应这里加上下面这段:

// formObject.js
ball.render.text = Math.pow(2,newLevel);

来设定 render 中的 text 属性,数字我们就依 2048 的设计,照 2^n 的方式显示。

同时上面这个函式,我们要挂到一个事件触发来让它持续更新 canvas,这边笔者会用 afterUpdate 这个 Event,也就是处理完所有碰撞及引擎事件後。

// main.js
Events.on(engine, "afterUpdate", renderText);

合并计分

https://ithelp.ithome.com.tw/upload/images/20211014/201420570eGQFX3qMZ.png
说到游戏,常常少不了计分,我们也不能免俗,我们准备了两个栏位,一个是分数,一个是历史高分。

那我们的 2048 什麽时候加分呢?当我们球体发生碰撞要合并的时候,除了合并,我们也会加上对应新的球体的 2^level 的分数,也就是当 2 与 2 合并的时候,我们要拿到 2^2 = 4分。

这边就很单纯地用全域变数来处理,记得 html 里要加上对应的 element,可以在 demo 的 html 里搜寻 scoreDisplay 和 bestScoreDisplay 这两个 id。

//main.js 
.
.
.
var score = 0;
var bestScore = localStorage.getItem('bestScore') ? localStorage.getItem('bestScore') : 0;
.
.
.
function updateScore(addAmount)
{
    score += addAmount;
    document.getElementById("scoreDisplay").innerHTML = score;
}

function updateBestScore(newBestScore)
{
    bestScore = newBestScore;
    localStorage.setItem('bestScore',bestScore);
    document.getElementById("bestScoreDisplay").innerHTML = bestScore;
}
.
.
.

我们暂且把更新显示逻辑与跟新分数本身逻辑放一块,毕竟暂时没想到更新分数但不更新显示的例子。另外我们的最高分会用 localStorage 来存,让玩家关掉网页後重开仍是有高分纪录的,关於 localStorage ,请参考 MDN 文件

而我们把更新最高分的逻辑放在结算时才一次更新,平常碰撞只会对分数做更动。

结束测定

天下没有不散的筵席, 2048 也有它结束的时候,我们要帮游戏设定一个结束条件,让玩家在限制条件内更有紧迫感。原始的 2048 就是不能滑动的时候就算输了,所以我们比照定义属於我们的结束条件:每次滑动、生出新的图形时,计算是否图形占达方框一定比例。

这边用比例是因为笔者不想算得太仔细,用约略抓出一个面积,然後靠比例因子大小来人肉调整就好。

// calculationHelper.js
function getTotalAreaOfBalls()
{
    var sum = 0;
    engine.world.bodies.filter(x=>Number(x.label)).forEach(x => sum += x.area);
    return sum;
}

function isAreaMeetFillGate()
{
    var totalArea = Math.pow(canvasWidth-wallThickness*2,2);
    var fillGateRate = 0.4;
    return totalArea*fillGateRate < getTotalAreaOfBalls();
}

这边拿取球体面积的时候我们一个透过 engine.world.bodies ,辨识球体一样用上面讲到,只有 label 是数字的才是我们创建的球体,接着把他们的面积加起来。

同时容器面积我们用 canvas 的宽度来乘算(因为我们的墙壁长度就是等同 canvas 的宽度!),扣去我们做 offset的部分,最後就是简单的算方形面积。

判别就是把我们上面的叙述程序化,拿总面积乘上一个比例因子,若全部球体面积加起来超过这个阈值,则判定要触发结束条件。

// swipedEvent.js
function swipeScreen(side)
{
		.
		.
		.

    if(isAreaMeetFillGate())
    {
        triggerEnding();
    }
}

function triggerEnding()
{
    stopTheRunner();
    if( score > bestScore)
    {
        updateBestScore(score);
        alert("You got "+ score + " points and it is NEW HIGH SCORE!\nWell done!");
    }
    else{
        alert("You got "+ score + " points!");
    }
    renderText();
}

结束就做几件事

  • 停止 runner,让玩家无法再操控
  • 判定是否更新历史高分
  • 依据上一则判定结果显示对应讯息
    https://ithelp.ithome.com.tw/upload/images/20211014/201420574ICQB8NJPj.png
  • 最後一次渲染文字,如果不做这个,最後结束的当下会没有文字

画面重置

最後的最後,就是当一场游戏完了,玩家又投了 10块钱想要续摊,那我们就要重制特定变数的状态与 Matter.js 部分相关内容,这个其实我们在 Day15 的弹珠台就做过了,这边笔者就借用一下自己写过的内容:

// main.js
function retry(){
    event.preventDefault();
    Engine.clear(engine);
    Render.stop(render);
    Runner.stop(runner);
    render.canvas.remove();
    render.canvas = null;
    render.context = null;
    render.textures = {};
    init();
}

一模模一样样!因为笔者都有把初始化抽到一个函式,这边就做完必要的 reset 後直接呼叫初始函式让他重新呼叫就好了!


以上,就是我们今天提到,要帮我们的 2048 做的所有功能,看看我们的 Demo ,是不是已经有模有样了呢?

其实今天笔者还有做一些调整,如碰撞生成後再次确认新生成的物体碰撞是否需合并,算式、变数或函式移动, html 主体更动等等,如果想看做了那些改动的,可以去用档案比较的方式看看 Day28 和 Day29 差在哪,配合这两天的文章,应该能够理解笔者的改动,若有疑问,也可以提出来讨论。

那我们的第二个实作,圆形 2048 到这边就要结束了,也是我们 30 天的旅程即将画上尾声的信号,明天最後一天,让我们来回首这 30 天的路程,同时俯瞰 Matter.js 的模组我们过去怎麽走、实际上分类的分法,重新回想这 30 天走过的路程,如果要推人坑,也可以用我们明天的内容来帮助他人更快上手XD。


<<:  Azure AutoML01

>>:  Youtube Reports API 教学 - 告一个段落

[DAY-27] 适合你的 才是真正的好职涯

经营你的职业生涯 人对於甚麽叫 理想职涯 都有一套自己的想法 在经营职涯时候 追求目标要有弹性 适...

Day26 React Router useLocation

useLocation 函数是当 URL 网址改变时useState()会返回一个新的包含有关目前U...

[13th][Day23] http response header(下)

接续昨天 response headers 的部分 一样是看 Julia Evans 大大的可爱的图...

什麽是帕累托图?(20/80法则)

我相信您曾经有过这样的经历。当您遇到问题并想解决时,您总是会发现有太多因素会影响该问题。太多了,您根...

[Day20]C# 鸡础观念- 物件导向(oop)基本观念

在程序语言中, 我们不只要掌握基本的语法, 还要去融会贯通, 掌握它的精随所在, 而物件导向正是C...