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


<<:  【心得】checkbox表单实作-待办清单

>>:  【领域展开 22 式】 初次认识 Jetpack 与启用

C# 入门之类(Class)

在前面介绍 C# 代码结构的时候,我们有提高过一下类(class),下面我们来看一下,如何定义一个类...

day12_Linux Arm 的游戏之旅了

Linux 可以玩游戏吗? Linux 当然可以玩游戏,着名的线上游戏商店 Steam 所出的专属游...

Day23,替你的Gitlab pipeline 添加点搞事

正文 在前面介绍gitlab-ci的pipeline中我仅仅只用到了build stage作为con...

Java:观念厘清(新手用)-单元运算子a++与++a的差异

本篇用记录笔者在上课时,笔记a++与++a的差异。 单看结果虽然都是一样,但是搭配其他运算及操作时,...

[Day 18] 实作 - 介面篇2

首先看一下原生的技能介面是怎麽生成的 游戏介面分成场景(Scene)跟视窗(Window) 透过在S...