iT邦幫忙

2021 iThome 鐵人賽

DAY 30
0
Modern Web

從零開始打造網頁遊戲-造輪子你也辦的到!系列 第 30

Chapter5 - 不介意的話,請玩玩看這個Canvas遊戲!試圖拾回一片片的落葉,拯救這顆樹吧

(10/11更)私下有一些朋友反應手機不太能玩,我才想起這個問題,所以有對此demo在長版進行微調,原文主要講解橫版(電腦端)的處理

先給大家看看成果吧!
https://jerry-the-potato.github.io/Chapter5-demo2/

開場畫面三部曲(從生長到凋零)

https://ithelp.ithome.com.tw/upload/images/20211008/20135197JHuzpSwOu7.png
https://ithelp.ithome.com.tw/upload/images/20211008/20135197EEKYx58SfK.png
https://ithelp.ithome.com.tw/upload/images/20211008/20135197fFoQXB6IQV.png

3. 遊戲進行畫面(GamingScreen)

昨天做到這就中斷了,說實話後面也比較複雜一點,目前的框架似乎有一點難hold住,講解起來可能會有一點混亂,總之就講重點,把實現的方法講清楚就好!

function GamingScreen(){
    try{
        Resize("#game-box", canvas, context, '#000');
        Redraw(); // 主動畫
        
        // 遊戲結束時(音樂播放完畢)
        if(audio.ended){ 
            // 一樣可以設置轉場動畫,等轉場結束回到初始畫面
            if(true){ // 這邊設置true直接跳轉
                header.style.pointerEvents = "auto"; // 重新啟用選單事件(主要是點擊)
                startScreen.style.display = "block"; // 重新顯示起始畫面
                myTree = new Tree(WIDTH/2, 0.8 * HEIGHT, HEIGHT/6, 90, maxTimes);
                loadingAnime = requestAnimationFrame(LoadingScreen);
                return;
            }
        }
    }catch(e){
        console.log(e);
        return;
    }
    if(audio.paused) return;
    gamingAnime = requestAnimationFrame(GamingScreen);
}

還沒時間做結算畫面,等遊戲結束時就直接回到起始畫面
話說loadingAnime和gamingAnime其實都表示當下的編號,不需要用不同變數存,這邊只是我想留下每個場景最後的紀錄,才分別用不同變數儲存。

遊戲主體:

分為兩部分,一個是遊戲的主畫面,會受運鏡影響視野範圍,包含樹、樹葉、落葉等等;另一個是UI視窗,不受運鏡影響,包含滑鼠、分數等等資訊:

function Redraw(){
    clear(context);
    AudioProcess();
    
    // 1. 運鏡會改變整體座標
    let x = camera.pointX * WIDTH;
    let y = camera.pointY * HEIGHT - (myMouse.pointY - 0.5 * HEIGHT) * 0.1;
    context.translate(x, y);
    myTree.Transform();
    myTree.Draw();  // 畫樹
    AnimeProcess(); // 樹葉掉落
    context.translate(-x, -y);
    
    // 2. 以下為UI介面不受運鏡影響
    MouseAnime();   // 滑鼠追蹤
    context.font = WIDTH * 0.02 + 'px IBM Plex Sans Arabic';
    context.fillStyle = 'rgba(179, 198, 213, 1)';
    context.fillText("分數: " + Math.floor(gameScores/RATIO),
                        WIDTH * 0.55, HEIGHT * 0.90);
    context.fillText("生命值: " + leafNodes.length + " / " + lifePoint,
                        WIDTH * 0.55, HEIGHT * 0.95);
}

在運鏡的地方除了camera也增加了滑鼠可以上下移動,來改變視野的效果,可以把整棵樹看得更清楚,不添加水平座標,避免玩家覺得頭暈。

其他都是沿用以前文章的內容,就不特別說明了。

細節

今天修了蠻多地方,有點難描述,一個一個來吧!另外因為程式碼一次貼上,會太多造成閱讀困難,因此只要是之前講過的「相同內容」,都會以略過的方式,只有針對我們要修改的地方給大家看,如果想看得更完整可以去github唷!

3-1 樹葉生長動畫

在樹葉節點誕生之初新增了幾個屬性:

let Tree = function(x, y, r, theta, times, min = 15){
    treeNodes = [this];
    leafNodes = [];
    fallingNodes = [];  // 新增正在等待掉落的落葉隊列
    // ...以下略
}
let Stick = function(father, shrink_diff, angleOffset, times){
    // ...以上略
    if(this.r < this.min || times < 0){
        leafNodes.push(this);
        this.growth = 0;        // 葉子的成長值(最大為1)
        this.growing = true;    // 葉子是否成長中
        this.falling = false;   // 葉子是否正在掉落
        this.img = pngImg['1'][2 + Math.floor(random(2))];
        return this;
    }
    // ...以下略

並且在繪製樹葉(樹上的)的地方做了結構的修改,

for(let N = 0; N < leafNodes.length; N++){
    let node = leafNodes[N];
    if(node.growing == true){ // 如果還在成長中
        node.growth = Math.min(1, node.growth * 1.005 + 0.002); // 成長
        if(node.growth == 1){ // 如果已經長到最大
            fallingNodes.push(node);  // 在掉落的前置陣列中放入該節點
            node.growing = false;     // 停止成長
        }
    }
    if(node.falling == false){ // 如果葉子尚未掉落
        // ...略
        // 畫樹葉
    }
}

增加這些設定是為了後面「隨著音樂使葉子掉落的功能」

3-2 樹葉掉落動畫

此時this.falling還是false,因為還沒開始掉落,要等到音樂播放的時候,還記得有一段代碼:

if(v1 > 0 && v2 > 0 && v3 <= 0){
    let times = 100 * Math.max(v1, v2) / maxVolume; // 100乘上一個0~1之間的數
    new animeObject(times * 0.3, 'Falling');
}

這次我把動畫的物件建立簡化成以上的方式,沒有立刻push到動畫清單animeList裡面,原因是我們要先判斷「樹上是否有樹葉」等待掉落,也就是:

function animeObject(){
    // ...基礎設定
    // 以上略
    
    // 樹上如果沒有樹葉等待掉落,該陣列的長度則為0,則不會把物件送到動畫清單
    if(fallingNodes.length){
        // 隨機取用一個樹葉
        let ranIndex = Math.floor(Math.random() * fallingNodes.length);
        this.node = fallingNodes[ranIndex];
        
        // 檢查圖片來源是否有問題
        if(this.node.img == undefined) return;
        
        // 把我們剛剛設置的falling狀態改成true,靜態樹葉就不會再被繪製
        this.node.falling = true;
        
        // 以公轉角度去代入落葉的動畫公式
        this.beginX = this.node.father.father.endX - this.period * WIDTH * 0.02 * Math.sin(this.revolveTheta);
        this.beginY = this.node.father.father.endY - this.period * HEIGHT * 0.01 * Math.sin(this.revolveTheta * this.period);
        
        // 繼承原本樹葉的屬性
        this.rotateTheta = -Math.PI / 4 - this.node.theta / 180 * Math.PI;
        this.size = this.node.r * 1.5 / WIDTH;
        this.img = this.node.img;
        
        // 前面都沒問題,則做最後處理:
        // 1. 把該樹葉從等待掉落的陣列中移除
        fallingNodes.splice(ranIndex, 1);
        // 2. 把該樹葉送到動畫清單中
        animeList.push(this);
        // 3. 根據音樂脈衝決定是否掉落更多樹葉
        if(times > 5) new animeObject(Math.pow(times, 0.9), animeName);
    }
}

座標公式可對照下面的Falling方法

除此之外,我們還要修改一下落葉的大小變化,當初第四章設計從0-1-0變大再變小,這邊因為是從樹上掉落,因此是原始大小1-0,持續變小,我們可以簡單的寫:

animeObject.prototype.Falling = function(dT){
    let revolveNow = this.revolveTheta + this.revolveOmega * dT;
    let A = Math.sin(revolveNow);
    let C = Math.sin(revolveNow * this.period);
    this.pointX = this.beginX + this.scaleX * (this.period * WIDTH * 0.02 * A + WIDTH * 0 * dT);
    this.pointY = this.beginY + this.scaleY * (this.period * HEIGHT * 0.01 * C + HEIGHT * 0.04 * dT);
    this.sizeNow = WIDTH * this.size * (1 - 1 * dT / this.lifeTime);
}

最一開始dT為0,因此

3-3 樹葉收集動畫

接下來這邊要注意,因為剛才有運鏡設定,所以樹葉在空間中的座標,和滑鼠的座標,是一個相對值,也就是:

let x = camera.pointX * WIDTH - 0 * (myMouse.pointX - 0.5 * WIDTH) * 0.1;
let y = camera.pointY * HEIGHT - (myMouse.pointY - 0.5 * HEIGHT) * 0.1;

在做碰撞檢測時,要把它考慮進去:

let distance2p = Math.pow(this.pointX + x - myMouse.pointX, 2) + 
                 Math.pow(this.pointY + y - myMouse.pointY, 2);
let mouseWidth = WIDTH * 0.05;
let thisWidth = this.sizeNow;
if(distance2p < Math.pow((mouseWidth * 0.4 + thisWidth * 0.5), 2)){
        // 加分,落葉救得越早分數越多
        gameScores+= this.sizeNow;

        // 用另一個物件取代該物件
        let newObject = new animeObject2(this.node, this.rotateTheta,
                                         this.img, this.sizeNow,
                                         this.pointX, this.pointY,
                                         this.beginX, this.beginY, 60);
        let index = animeList.indexOf(this);
        delete animeList[index];
        animeList[index] = newObject;
}

在觸碰到樹葉的當下進行加分,並且用一個新的動畫物件取代,只保留部分屬性,this.node是今天才設計的,也要一起被保留下來

UI介面就簡單設計

把音樂播放和處理的函式AudioProcess放入開場畫面,在進遊戲前,就用一段放鬆的音樂,先讓玩家看到,有一棵樹會隨著音樂掉落,也算是賞心悅目,然後準備若干個按鈕元件:

<ul id="game-menu">
    <li><button id="Play">Play</button></li>
    <li><button id="How">How to Play</button></li>
    <!-- <li><button id="Pause">Pause</button></li> -->
    <li>
        <select id="Select">
            <option>- Select -</option>
            <option value="Advertime.mp3">Advertime</option>
            <option value="Brothers Unite.mp3">Brothers Unite</option>
            <option value="Horizon Flare.mp3">Horizon Flare</option>
            <option value="Lovely Piano Song.mp3">Lovely Piano Song</option>
            <option value="Motions.mp3">Motions</option>
        </select>
    </li>
    <li><button id="Learn">Learn more</button></li>
</ul>

有基本的遊玩、遊戲方法、選擇曲子、了解更多四個選項

接著設計一個對話框,是我從第二章的demo一直用到現在,中間都有用,概念就很簡單:

<div id="dialog-box">
    <h3 id="dialog">預設曲目: Lovely Piano Song.mp3</h3>
</div>

然後在事件監聽中做不同處理:

let How = document.querySelector("#How");
let Learn = document.querySelector("#Learn");
let dialog = document.querySelector("#dialog");
How.addEventListener("click", function(){
    dialog.textContent = "樹葉會隨著音樂,不斷的掉落,玩家需要移動滑鼠,接住落葉,讓樹避免枯萎的命運";
});
Learn.addEventListener("click", function(){
    dialog.textContent = "本遊戲出自「從零打造網頁遊戲,造輪子你也辦的到!」教學文--2021年度鐵人賽--by Jerry, the Potato";
});

其他兩個按鈕是之前就做過的功能,這邊就不佔篇幅了

然後...完結灑花~~~

終於,完賽了嗚嗚,還好最後有把遊戲做出來^u^

然後才發現完全不是休閒遊戲,玩的時候要拼命接樹葉,還要頂著樹木枯萎的壓力www


上一篇
Chapter5 - 輕鬆用Canvas實現轉場動畫和運鏡處理
下一篇
鐵人賽後感言 - 趣聞分享、30天回顧、四大收穫、Canvas遊戲後續發展
系列文
從零開始打造網頁遊戲-造輪子你也辦的到!31

尚未有邦友留言

立即登入留言