來到2048的最後一天!看看這麼多的刪除線!雖然可能我們不一定能清光我們購物網站上的願望清單,但是今天,讓我們一起清空這裡列的代辦清單。
另外我們會加上這些元素,來讓我們的整體遊戲更完滿。
我們稍微依照事件發生的順序,排序一下需求,下面我們就照著這個順序來處理:
(我把 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 的大神會告訴你該怎麼好好寫的。
文字附加指的是在球上追加文字顯示,讓玩家知道他現在每顆球是多少,這個對玩家的遊戲體驗是一個很大的幫助(筆者個人觀感)。
儘管先前我們說過 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);
說到遊戲,常常少不了計分,我們也不能免俗,我們準備了兩個欄位,一個是分數,一個是歷史高分。
那我們的 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();
}
結束就做幾件事
最後的最後,就是當一場遊戲完了,玩家又投了 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。