暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

在HarmonyOS上绘制一条游动的锦鲤

鸿蒙技术社区 2022-02-10
319

今天给大家带来如何用纯 JS 代码的 Canvas 绘制一条游动的锦鲤。


先看下效果图:

实现思路


①拆分


先看设计图,我们的小鱼由头、鳍、身体、节肢、尾部五部分组成。


下面给出每个部位的实现 api:

  • 头是一个实心圆,可以通过 canvas.arc()

  • 鱼鳍则由一条直线和曲线构成,会用到 path,曲线可以使用贝塞尔曲线 canvas.quadraticCurveTo()

  • 身体由两条直线和曲线构成,曲线也选用贝塞尔曲线,通过控制点可以控制小鱼胖瘦

  • 节肢 1 由两个实心圆和一个梯形构成。梯形可以看出四条封闭的线段构成

  • 节肢 2 由一个梯形和一个圆,方案同上

  • 尾由两个三角形,也是封闭线段


②参数


基准尺寸参数:首先我们定一个基准尺寸参数来控制小鱼的大小,这里我们选取鱼头的半径 R。


其他参数如下:

小鱼身长设为:3.2R
节肢1大圆的半径设为:0.7R
节肢1中圆的半径设为:0.7*0.6R=0.42R
节肢1梯形高度设为:(0.7+0.42)R=1.12R
节肢2小圆的半径设为:0.4*0.42R=0.168R
节肢2梯形高度设为:0.42*(0.4+2.7)R=1.302R
小鱼的总长度:R+3.2R+1.12R+0.168R+1.302R=6.79R
画布的长宽:2*(6.79R-R-1.6R)=2*4.19R=8.38R


小鱼的整体尺寸如下:

值得注意的是,画布的长度并不等于小鱼的长度,因为小鱼的重心并不位于鱼长中心,而是小鱼身的中心点,但是小鱼的转身却要围绕它。


因此我们画布的半径需要拓展成小鱼身中心到小鱼尾的长度,整个画布的大小=4.19R*4.19R,如下:

基准旋转参数:为了使我们的小鱼能够左右掉头,这里就需要一个旋转角度,我们选取 fishMainAngle 作为主角度。


实现步骤


自定义一个 Canvas 的组件:

<canvas ref="fishcanvas" id="fishcanvas"></canvas>


持有绘图相关的上下文:

onAttached() {
    setTimeout(() => { //这里需要延迟得到canvas
        this.canvas = this.$refs.fishcanvas.getContext('2d', {antialias: true});
        this.onDraw()
    },200)
}


这里我们延迟 200ms,在 js 中获取 hml 定义的 fishcanvas 组件,再拿到 context 并持有。接着执行 onDraw 进行绘制。

定义绘制方法:

onDraw() {
    //清除画布
    this.clearCanvas()
    //绘制小鱼头
    this.drawHead(this.canvas,fishAngle)
    //绘制左右小鱼鳍
    this.drawFins(this.canvas, leftFinsPoint, this.FIND_FINS_LENGTH, fishAngle, false);
    this.drawFins(this.canvas, rightFinsPoint, this.FIND_FINS_LENGTH, fishAngle, true);
    //绘制节肢1
    let middleCircleCenterPoint = this.drawSegment(this.canvas, bodyBottomCenterPoint,
        this.BIG_CIRCLE_RADIUS,
        this.MIDDLE_CIRCLE_RADIUS,
        this.FIND_MIDDLE_CIRCLE_LENGTH, fishAngle, true);
    //绘制节肢2
    this.drawSegment(this.canvas, middleCircleCenterPoint, this.MIDDLE_CIRCLE_RADIUS, this.SMALL_CIRCLE_RADIUS,
        this.FIND_SMALL_CIRCLE_LENGTH, fishAngle, false);
    let findEdgeLength = Math.abs(Math.sin(MathUtils.toRadians(this.currentValue * 1.5)) * this.BIG_CIRCLE_RADIUS);
    // 绘制小鱼尾大小三角形
    this.drawTriangle(this.canvas, middleCircleCenterPoint, this.FIND_TRIANGLE_LENGTH, findEdgeLength, fishAngle);
    let findEdgeLengthS = Math.abs(Math.sin(MathUtils.toRadians(this.currentValue * 1.5 - 90)) * this.BIG_CIRCLE_RADIUS);
    this.drawTriangle(this.canvas,middleCircleCenterPoint,this.FIND_TRIANGLE_LENGTH*0.8,findEdgeLengthS*0.8,fishAngle);
    // 绘制小鱼身
    this.drawBody(this.canvas, this.headPoint, bodyBottomCenterPoint, fishAngle)
},


下面分步骤说明:

①清除画布


每次绘制前我们都需要将画布擦除,不然会污染后续绘制:

this.canvas.clearRect(00this.width(), this.height());


②绘制小鱼头


绘制小鱼头,需要小鱼头中心坐标,小鱼头半径。小鱼头中心坐标可由重心坐标 middlePoint、距离、当前角度推算出来。

drawHead(canvas,fishAngle) {
    canvas.fillStyle = 'rgba(244, 92,71,0.5)'
    drawCircle(canvas, this.headPoint, this.HEAD_RADIUS)
    //绘制小鱼眼
    this.drawEye(canvas,fishAngle)
},


注意画布坐标轴是原则在左上角,x 轴向右,y 轴向下:

绘制小鱼眼,小鱼眼由两个椭圆实现,使用 canvas.ellipse() 绘制:

drawEye(canvas, fishAngle) {
    canvas.fillStyle = 'rgba(0, 0,0,1)'
    //左眼
    canvas.beginPath();
    let leftEye = MathUtils.calculatePoint(this.headPoint, this.HEAD_RADIUS * 0.7, fishAngle + 45)
    canvas.ellipse(leftEye.x, leftEye.y,
        this.HEAD_RADIUS * 0.12this.HEAD_RADIUS * 0.07, -Math.PI * 0.3,
        Math.PI * 0Math.PI * 21
    );
    canvas.fill();
    //右眼
    canvas.beginPath()
    let rightEye = MathUtils.calculatePoint(this.headPoint, this.HEAD_RADIUS * 0.7, fishAngle - 45)
    canvas.ellipse(rightEye.x, rightEye.y,
        this.HEAD_RADIUS * 0.12this.HEAD_RADIUS * 0.07Math.PI * 0.3,
        Math.PI * 0Math.PI * 21
    );
    canvas.fill();
}


③绘制小鱼鳍


利用 startPoint,controlPoint,endPoint 绘制贝塞尔的封闭 path:

drawFins(canvas, startPoint, length, fishAngle, isRightFins) {
    canvas.fillStyle = 'rgba(244, 92,71,0.5)'
    let controlAngle = 115;
    // 结束点
    let endPoint = MathUtils.calculatePoint(startPoint, length, fishAngle - 180);
    // 控制点
    let controlPoint = MathUtils.calculatePoint(startPoint, 1.8 * length,
            isRightFins ? fishAngle - controlAngle : fishAngle + controlAngle
    );
    drawPath(canvas, ['M', startPoint, 'Q', controlPoint, endPoint])
},


<img src=“https://s2.loli.net/2022/01/19/MvL6PikmRJSYdDW.png” alt=“image-20220119095554936” style=“zoom: 67%;” />


④绘制节肢


节肢由圆、梯形构成,先绘制圆再讲梯形绘制上:

/**
 * 画节肢
 *
 * @param bottomCenterPoint     梯形底部的中心点坐标(长边)
 * @param bigRadius             大圆的半径
 * @param smallRadius           小圆的半径
 * @param findSmallCircleLength 寻找梯形小圆的线长
 * @param isBigCircle          是否有大圆
 */

drawSegment(canvas, bottomCenterPoint, bigRadius,
            smallRadius, findSmallCircleLength, fishAngle,
            isBigCircle) {
    canvas.fillStyle = this.defaultFillStyle
    // 节肢摆动的角度
    let segmentAngle = 0;
    // 节肢1 用 cos
    if (isBigCircle) {
        segmentAngle = (fishAngle + Math.cos(MathUtils.toRadians(this.currentValue * 1.5)) * 15);
    } else {
        segmentAngle = (fishAngle + Math.sin(MathUtils.toRadians(this.currentValue * 1.5)) * 35);
    }
    segmentAngle = fishAngle +this.currentValue
    // 梯形上底的中心点(短边)
    let upperCenterPoint = MathUtils.calculatePoint(bottomCenterPoint, findSmallCircleLength, segmentAngle - 180);
    // 梯形的四个顶点
    let bottomLeftPoint = MathUtils.calculatePoint(bottomCenterPoint, bigRadius, segmentAngle + 90);
    let bottomRightPoint = MathUtils.calculatePoint(bottomCenterPoint, bigRadius, segmentAngle - 90);
    let upperLeftPoint = MathUtils.calculatePoint(upperCenterPoint, smallRadius, segmentAngle + 90);
    let upperRightPoint = MathUtils.calculatePoint(upperCenterPoint, smallRadius, segmentAngle - 90);

    if (isBigCircle) {
        // 绘制大圆
        drawCircle(canvas, bottomCenterPoint, bigRadius);
    }
    // 绘制小圆
    drawCircle(canvas, upperCenterPoint, smallRadius);

    // 绘制梯形
    drawPath(canvas, ['M', bottomLeftPoint, 'L', upperLeftPoint, 'L', upperRightPoint, 'L', bottomRightPoint])
    return upperCenterPoint;
},


绘制小鱼尾大小三角形:

drawTriangle(canvas, startPoint, findCenterLength, findEdgeLength, fishAngle) {
    canvas.fillStyle = 'rgba(244, 92,71,0.5)'
    // 三角形小鱼尾的摆动角度需要跟着节肢2走
    let triangleAngle = (fishAngle + Math.sin(MathUtils.toRadians(this.currentValue * 1.5)) * 35);

    // 底部中心点的坐标
    let centerPoint = MathUtils.calculatePoint(startPoint, findCenterLength, triangleAngle - 180);
    // 三角形底部两个点
    let leftPoint = MathUtils.calculatePoint(centerPoint, findEdgeLength, triangleAngle + 90);
    let rightPoint = MathUtils.calculatePoint(centerPoint, findEdgeLength, triangleAngle - 90);
    // 绘制三角形
    drawPath(canvas, ['M', startPoint, 'L', leftPoint, 'L', rightPoint])
    //console.log(`makeTriangle#startPoint:${JSON.stringify(startPoint)},leftPoint:${JSON.stringify(leftPoint)},rightPoint:${JSON.stringify(rightPoint)}`)
},


⑤绘制小鱼身


首先得到小鱼身的四个顶点,在得到左右两边的两个控制点,根据这六个点绘制一个封闭的 path:

drawBody(canvas, headPoint, bodyBottomCenterPoint, fishAngle) {
    this.canvas.globalAlpha = 160 / 255 //加深小鱼身
    // 身体的四个点
    let topLeftPoint = MathUtils.calculatePoint(headPoint, this.HEAD_RADIUS, fishAngle + 90);
    let topRightPoint = MathUtils.calculatePoint(headPoint, this.HEAD_RADIUS, fishAngle - 90);
    let bottomLeftPoint = MathUtils.calculatePoint(bodyBottomCenterPoint, this.BIG_CIRCLE_RADIUS,
        fishAngle + 90);
    let bottomRightPoint = MathUtils.calculatePoint(bodyBottomCenterPoint, this.BIG_CIRCLE_RADIUS,
        fishAngle - 90);

    // 二阶贝塞尔曲线的控制点
    let controlLeft = MathUtils.calculatePoint(headPoint, this.BODY_LENGTH * 0.56, fishAngle + 130);
    let controlRight = MathUtils.calculatePoint(headPoint, this.BODY_LENGTH * 0.56, fishAngle - 130);

    // 画小鱼身
    drawPath(canvas, ['M', topLeftPoint, "Q", controlLeft, bottomLeftPoint, "L", bottomRightPoint, "Q", controlRight, topRightPoint])
    this.canvas.globalAlpha = 100 / 255
    },


⑥小鱼的摆动


我们通过不断改变小鱼的旋转角度 fishAngle 来模拟小鱼的摆动,这里可以使用定时器,更为方便的可以使用 Animator:

startAnimation() {
    if (this.animator == null) {
        //fill: "none" | "forwards" | "backwards" | "both";
        //direction: "normal" | "reverse" | "alternate" | "alternate-reverse";
        var options = {
            duration: 1  * 1000,
            easing: 'ease',
            iterations: -1,
            direction: "alternate",
            fill: "none",
            begin: -5,
            end: 5
        };
        this.animator = animator.createAnimator(options)

    }
    this.animator.play();
}


这里 startAnimation 方法中我们创建了一个 Animator,并指定 option,option 中的参数说明如下:

var options = {
    duration: 1  * 1000,    //动画的时长为1s
    easing: 'ease',            //动画的插值曲线,ease表示先加速在减速
    iterations: -1,            //动画的循环次数,默认是1,-1表示无线循环
    direction: "alternate"//每次动画播放方向,alternate表示先正向播放在反向播放
    fill: "none",            //动画结束后是否动画状态设置,none表示设置为动画初始值
    begin: -5,                //动画插值起始值,这里表示小鱼的起始角度为-5°
    end: 5                    //动画插值结束值,这里表示小鱼的结束角度为5°
};


创建完动画我们通过指定 onframe 回调,currentValue 是动画变化因子,currentValue 影响小鱼角度。


改完 currentValue,我们调用 onDraw() 进行重绘制:

this.animator.onframe = (value)=>{
    this.currentValue = Number(value)
    this.onDraw()
};


onDraw(){
    let fishAngle = this.fishMainAngle+this.currentValue //得到当前小鱼的角度
    //清除画布
    this.clearCanvas()
    //绘制小鱼头
    this.drawHead(this.canvas)
    ....
}


效果如下:

这里虽然实现了小鱼的摆动,但是感觉怪怪的。日常小鱼摆动时,小鱼尾,节肢的幅度和频率要高于小鱼身体,这样才能不能显得呆板。幅度我们在小鱼尾和节肢那里增加一个currentValue相关乘积来扩大振幅。

drawSegment(canvas, bottomCenterPoint, bigRadius,
            smallRadius, findSmallCircleLength, fishAngle,
            isBigCircle) {
    canvas.fillStyle = this.defaultFillStyle
    // 节肢摆动的角度
    let segmentAngle = 0;
    if (isBigCircle) {
        segmentAngle = (fishAngle+this.currentValue*2); //节肢1增大2倍
    } else {
        segmentAngle = (fishAngle+this.currentValue*3); //节肢1增大3倍
    }


drawTriangle(canvas, startPoint, findCenterLength, findEdgeLength, fishAngle) {
        canvas.fillStyle = 'rgba(244, 92,71,0.5)'
        // 三角形小鱼尾的摆动角度需要跟着节肢2走
        let triangleAngle = fishAngle+this.currentValue*3;


那如何实现动画更新时,小鱼各部位的频率不同呢,这里就涉及小鱼的变频。


方案一:对于不同频率的部位分别创建一个 Animator,每个 Animator 分别管理相同部位的频率并绘制。交互比较复杂。


方案二:使用正弦函数的周期性。sin(nx) 的频率是 sin(x) 的 n 倍,如果小鱼的角度公式与 sin(nx) 相关,那么我们对不同部位设置不同的 n 值来实现频率不一致。

比如头的角度=sin(2x),尾的角度=sin(3x),那么尾的频率就是头的 1.5 倍。

onDraw() {
    this.clearCanvas(
    let fishAngle = (this.fishMainAngle + Math.sin(MathUtils.toRadians(this.currentValue * 1.2)) * 4);
...
}

drawSegment(canvas, bottomCenterPoint, bigRadius,
            smallRadius, findSmallCircleLength, fishAngle,
            isBigCircle) {
    canvas.fillStyle = this.defaultFillStyle
    // 节肢摆动的角度
    let segmentAngle = 0;
    if (isBigCircle) {
        segmentAngle = (fishAngle + Math.cos(MathUtils.toRadians(this.currentValue * 1.5)) * 15);
    } else {
        segmentAngle = (fishAngle + Math.sin(MathUtils.toRadians(this.currentValue * 1.5)) * 35);
    }


如上 fishAngle 的频率扩大了 1.2 倍,节肢的频率扩大了 1.5 倍。sin,cos 的角度变化范围,也就是 currentValue 的 end-begin,必须是 360 度的整数倍,这样才能保证 sin,cos 的周期性。

我们这里设置 begin=0,如下求 currentValue 的 end 值:

设m = end*1.2/360,n=end*1.5/360
m=end/(360/1.2),n=end/(360/1.5)
m=end/300,n=end/240
end要整除300240,则end是这两个数的最小公倍数
end最小为1200


下面是变频的 option:

var options = {
    duration: 5  * 1000,
    easing: 'linear',
    iterations: -1,
    direction: "normal",
    fill: "none",
    begin: 0,
    end: 1200 //必须是1200的倍数,1.5/360与1.2/360的最小公倍数是1200
}


调用:

<element src="../fish/fish.hml"></element>
<div class="container">
    <fish id="fish"></fish>
</div>


总结


通过本项目的演练,我们对自定义 Canvas,JS 动画,三角函数等有了更深的认识。


这里只是实现了小鱼摆动和变频,后续有时间会接着增加点击屏幕实现小鱼的游动、游动时的转向、转向变频。

源码地址:

https://gitee.com/freebeing/swimming-fish


作者:包月东

👇扫码报名下周三的鸿蒙直播课👇

👇点击关注鸿蒙技术社区👇
了解鸿蒙一手资讯

求分享

求点赞

求在看

文章转载自鸿蒙技术社区,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论