前情提要:讲解完canvas的基本知识与水果绘制,本文我们讲解切割模型绘制。

切割模型绘制

话不多说,献上代码

ctx.beginPath();
                ctx.moveTo(sliceTrail[0].x, sliceTrail[0].y);
                for (let i = 1; i < sliceTrail.length; i++) {
                    ctx.lineTo(sliceTrail[i].x, sliceTrail[i].y);
                }
                ctx.lineWidth = 20;
                ctx.strokeStyle = 'blue';
                ctx.lineCap = 'round';
                ctx.lineJoin = 'round';
                ctx.stroke();
                ctx.shadowColor = '#FFF';
                ctx.shadowBlur = 10;
                ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
                ctx.lineWidth = 14;
                ctx.stroke();
                ctx.shadowBlur = 3;

该代码的显示效果:

上面蓝色轨迹为切割轨迹

OK,那我按顺序讲解

路径遍历

ctx.moveTo(sliceTrail[0].x, sliceTrail[0].y);

将绘图的起始点移动到sliceTrail数组中第一个点的坐标位置。moveTo方法用于设置路径的起点。

 for (let i = 1; i < sliceTrail.length; i++) 

遍历sliceTrail数组中的点,从第二个点开始(索引为1)。因为第一个点已经用moveTo设置为起点。

ctx.lineTo(sliceTrail[i].x, sliceTrail[i].y)

从当前路径的终点(上一个点)绘制一条直线到当前点sliceTrail[i]的坐标位置。lineTo方法用于在路径中添加直线段。

线条细节

ctx.lineCap = 'round';

    设置线条端点的样式为圆形。

    lineCap属性可以取以下值:

    butt:默认值,端点为平头。

    round:端点为圆形。

    square:端点为方形,超出线条长度一半的宽度。

    ctx.lineJoin = 'round';

    设置线条连接点的样式为圆形。

    lineJoin属性可以取以下值:

    round:连接处为圆形。

    bevel:连接处为斜角。

    miter:连接处为尖角(默认值)。

    线条阴影

    ctx.shadowColor = '#FFF'

    设置阴影的颜色为白色(#FFF)。

    ctx.shadowBlur = 10;

    设置阴影的模糊程度为10像素。其值越大,阴影越模糊。

    颜色叠加

    通过两个ctx.strokeStyle,形成颜色叠加效果,使线条更具层次与立体感。


    切割判定

    献上我项目的源代码:

    //调用函数,如果点到线段(切割轨迹)的距离小于对象的半径(this.size / 2),说明切割路径与对象相触碰,触发切割
                checkSlice(slicePath) {
                    for (let i = 1; i < slicePath.length; i++) {
    
                    //获取切割前一点的坐标
                        const x1 = slicePath[i - 1].x;
                        const y1 = slicePath[i - 1].y;
                        
                    //获取当前切割点的坐标
                        const x2 = slicePath[i].x;
                        const y2 = slicePath[i].y;
                        const distance = distToSegment(this.x, this.y, x1, y1, x2, y2);//圆心到线段的距离
                        if (distance < this.size / 2) {
                            this.sliceAngle = Math.atan2(y2 - y1, x2 - x1);//计算切割角度
                        
                            this.slicePart1.x = this.x;
                            this.slicePart1.y = this.y;
                            this.slicePart1.vx = this.velocityX - 1 + Math.random() * 2;//水平速度减1,防止切割后两部分粘合在一块
                            this.slicePart1.vy = this.velocityY - 2;                    //竖直向上速度加2,形成抛射效果
                            //更新切割后的位置
                            this.slicePart2.x = this.x;
                            this.slicePart2.y = this.y;
                            this.slicePart2.vx = this.velocityX + 1 + Math.random() * 2;
                            this.slicePart2.vy = this.velocityY - 2;
                            this.sliced = true;
                            
                            //记住要想切割后生成小水果,返回的类型一定要包含它
                            if (this.type === 'bomb') return 'bomb';
                            if (this.type === 'banana') return 'banana';
                            if (this.type === 'Orange') return 'Orange';
                            if(this.type === 'watermelon') return 'watermelon';
                            return 'fruit';
                        }
                    }
                    return false;
                }
            }

    该代码用到的数学辅助函数:

    // 辅助函数
            function sqr(x) { return x * x; }  //平方计算
            function dist2(v, w) { return sqr(v.x - w.x) + sqr(v.y - w.y); }   //两点距离
            function distToSegmentSquared(p, v, w) {  //点到线段距离平方
                const l2 = dist2(v, w);
                if (l2 === 0) return dist2(p, v);
                let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2;
                t = Math.max(0, Math.min(1, t));
                return dist2(p, { x: v.x + t * (w.x - v.x), y: v.y + t * (w.y - v.y) });
            }
            
            //点到线段距离,px,py,指点的坐标,x1,y1,x2,y2的两点连线组成线段
            function distToSegment(px, py, x1, y1, x2, y2) {
                return Math.sqrt(distToSegmentSquared({ x: px, y: py }, { x: x1, y: y1 }, { x: x2, y: y2 }));
            }

    基本判定的数学思想

    水果圆心到线段的距离小于半径,则水果会被切割

    那么大家可能会疑惑“线段”是什么呢?来看下面的代码

    for (let i = 1; i < slicePath.length; i++) {
    
                    //获取切割前一点的坐标
                        const x1 = slicePath[i - 1].x;
                        const y1 = slicePath[i - 1].y;
                        
                    //获取当前切割点的坐标
                        const x2 = slicePath[i].x;
                        const y2 = slicePath[i].y;

    切割前后两点连线即为线段。这里获取的时间间隔为16.67 毫秒,可见速度还是非常快的。

    小插曲

    小伙伴大概率会发现下面的代码:

     if (this.type === 'bomb') return 'bomb';
                            if (this.type === 'banana') return 'banana';
                            if (this.type === 'Orange') return 'Orange';
                            if(this.type === 'watermelon') return 'watermelon';
                            return 'fruit';
                        }
                    }
                    return false;
    

    你是不是会有疑惑呢?

    有疑惑就对了,因为这个涉及到作者项目中切掉大水果生成小水果的功能与迭代思想,目前作者还未公布与介绍相关代码,但作者会在后面的文章中更新哦

    切割机制限制

    先看代码:

     if (sliceTrail.length > 2000 && !sliceActive) sliceTrail.shift();

    上述表示如果线条长度超过2000 或者 切割未激活,则切割线条消失。

    切割连击设置

    献上代码:

    // 处理连击
                if (fruitSlicedThisFrame > 0.5) {
                    comboCount = fruitSlicedThisFrame;//将当前帧切割的水果数量赋值给 comboCount,更新连击计数
                    comboTimer = 0;
                    const comboBonus = comboCount * 5;//每连击一个水果奖励 5 分
                    score += comboBonus;
                    scoreDisplay.textContent = score;
                    showComboText(comboCount);
                } else if (fruitSlicedThisFrame === 1) {
                    comboTimer += deltaTime;
                    if (comboTimer > 200) comboCount = 0;//如果连击计时器超过0.2 秒,重置连击计数为 0,连击中断
                }

     第一个if代码内容:

    • 功能:处理玩家在当前帧切割了多个水果的情况。

    • 逻辑

      • 如果当前帧切割的水果数量大于0.5(0.5是为了方便显示特效),则将切割的水果数量赋值给comboCount,更新连击数。

      • comboTimer重置为0,表示连击计时器重新开始。

      • 计算连击奖励,每连击一个水果奖励5分。

      • 更新玩家得分,并刷新得分显示。

      • 调用showComboText函数,在屏幕上显示当前的连击

    else if后面全部内容:

    • 功能:处理玩家在当前帧切割了1个水果的情况。

    • 逻辑

      • 如果当前帧切割的水果数量等于1,则增加comboTimer的值,累加上一次游戏循环的时间间隔。

      • 判断计时器是否超过200毫秒(0.2秒)。如果超过,则认为连击中断,将comboCount重置为0

    总结

           通过fruitSlicedThisFramecomboTimer来判断玩家是否在短时间内连续切割水果,从而实现连击效果。如果玩家在短时间内连续切割水果,会增加连击数并给予额外的分数奖励,作者设计此机制,是为了增加游戏的趣味性。

    切割后视觉体验

    切割后肯定要做到前后分离的效果,才能让玩家知道自己成功切割水果。

    那作者是如何实现的呢?请看下面代码:

    this.slicePart1.vx = this.velocityX - 1 + Math.random() * 2;//水平速度减1,防止切割后两部分粘合在一块
    this.slicePart1.vy = this.velocityY - 2;                    //竖直向上速度加2,形成抛射效果
     this.slicePart2.vx = this.velocityX + 1 + Math.random() * 2;
     this.slicePart2.vy = this.velocityY - 2;

    上面两个基本相同的代码,却因为“-1"与“+1”的不同,造成切割后水果两部分前后分离,Y方向的“-2”帮助水果形成抛射效果。

    切割后水果绘制

    切割后生成的两部分当然是要绘制的,作者先献上自己的代码在向大家讲解

    请看代码:

                //切割后的第一部分
                draw() {
                    ctx.save();
                    if (this.sliced) {
                        ctx.save();
                        ctx.translate(this.slicePart1.x, this.slicePart1.y);//将绘图原点(坐标系的原点)移动到指定的坐标位置
                        ctx.rotate(this.slicePart1.rotation);  //将当前的绘图坐标系旋转指定的角度
                        ctx.beginPath();
                        ctx.arc(0, 0, this.size / 2, 0, Math.PI, false);
                        ctx.fillStyle = this.slicedColor1;
                        ctx.fill();
                        //这里的西瓜判定是因为西瓜是由 黄色的小圆 覆盖原本绿色的大圆形成·的,
                        //如果没有下面代码,切割后,只会显示绿色的半圆。因为没有绘制黄色的小圆
                        if (this.type === 'watermelon') {
                            ctx.beginPath();
                            ctx.arc(0, 0, this.size / 2 - 5, 0, Math.PI, false); //绘制圆形
                            ctx.fillStyle = this.slicedColor2;
                            ctx.fill();
                        }
                       ctx.restore();
    
                    //切割后的第二部分
                        ctx.save();
                        ctx.translate(this.slicePart2.x, this.slicePart2.y);
                        ctx.rotate(this.slicePart2.rotation);
                        ctx.beginPath();
                        ctx.arc(0, 0, this.size / 2, Math.PI, 2 * Math.PI, false);
                        ctx.fillStyle = this.slicedColor1;
                        ctx.fill();
                        if (this.type === 'watermelon') {
                            ctx.beginPath();
                            ctx.arc(0, 0, this.size / 2 - 5, Math.PI, 2 * Math.PI, false);
                            ctx.fillStyle = this.slicedColor2;
                            ctx.fill();
                        }
                        
                        ctx.restore();
                    } 

    部分canvas的画图知识,作者之前的文章中已经讲过了,大家有遗忘的话,可以翻看作者之前的文章。

    为方便大家查找,贴心的作者已经创建了水果忍者创作系列的免费专栏,将之前的文章放了进去,方便大家查找。

    既然已经讲过了,为何作者还会把这些代码放在这里呢?

    理由很简单,有一个项目重要知识点前文没有涉及到,它就是转换坐标系

    转换坐标系

    请看js代码:

    ctx.translate(this.slicePart1.x, this.slicePart1.y);//将绘图原点(坐标系的原点)移动到指定的坐标位置
    ctx.rotate(this.slicePart1.rotation);  //将当前的绘图坐标系旋转指定的角度

    1. ctx.translate(this.slicePart1.x, this.slicePart1.y);

    作用:将绘图原点(坐标系的原点)从默认的画布左上角(0, 0)移动到指定的坐标位置 (this.slicePart1.x, this.slicePart1.y)

    参数说明

    • this.slicePart1.x:表示在水平方向(X轴)上要移动的距离,单位是像素。

    • this.slicePart1.y:表示在垂直方向(Y轴)上要移动的距离,单位是像素。

    效果

    • 执行这行代码后,画布的原点会被移动到 (this.slicePart1.x, this.slicePart1.y) 这个点。之后绘制的所有图形都会以这个新原点为参考点进行定位。

    • 例如,如果原来在 (100, 200) 处绘制一个图形,执行 translate(50, 30) 后,新的绘制位置会是 (100+50, 200+30) = (150, 230)


    2. ctx.rotate(this.slicePart1.rotation);

    作用:将当前的绘图坐标系旋转指定的弧度(注意是以弧度为单位)

    参数说明

    • this.slicePart1.rotation:表示旋转的角度,单位是弧度(radians)。如果是以角度(degrees)为单位,需要转换为弧度,公式为:radians = degrees * (Math.PI / 180)

    效果

    • 执行这行代码后,整个坐标系会围绕当前原点(即之前通过 translate 设置的新原点)旋转指定的角度。

    • 之后绘制的图形会按照旋转后的坐标系进行定位和渲染

    • 例如,如果旋转了 90 度(Math.PI/2 弧度),那么原本在 X 轴正方向绘制的线会变成沿着 Y 轴正方向绘制。

    综合上述两个功能代码,方便物体在运动中完成相关绘制,增强游戏趣味性。


    总结

    作者总结了切割模型及其相关知识,方便大家更进一步了解水果忍者制作过程,方便大家也能自己做出类似的游戏。

    下篇文章的内容,作者将发布投票来决定该更新什么。投票将分为3个选项,分别是

    1.游戏初始界面升级优化内容

    2.电脑/手机相关操作事件的监听事件实现

    3.切割大水果后小水果的生成迭代,模仿汁水飞溅效果

    本文也是纪念作者总访问量破10000的文章,谢谢大家支持,期待您的关注

    Logo

    火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

    更多推荐