iT邦幫忙

2021 iThome 鐵人賽

DAY 29
0
Modern Web

在JS的世界碰碰撞撞乒乒乓乓!30天一起玩Matter.js!系列 第 29

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。


上一篇
Day28. 雖然今年是2021,但我們要做2048(2)
下一篇
Day30. 是結束,也能是開始 - Review
系列文
在JS的世界碰碰撞撞乒乒乓乓!30天一起玩Matter.js!30

尚未有邦友留言

立即登入留言