我用 Bézier 曲线创造了一个机器人

497 阅读9分钟

效果预览

%E6%95%88%E6%9E%9C%E9%A2%84%E8%A7%88.gif

可以先思考一下,如何使用代码实现上面的效果呢?

实现思路

开始之前,还是要从一个故事讲起,在一个遥远的未来世界,有一个独特的机器人,它的名字叫做小白,小白不像传统的机器人那样呆板,而是由流畅的曲线和精巧的结构构成。它的头部,特别引人注目。开始的时候,它的头部只是一个简单的贝塞尔曲线构成的轮廓,仿佛是一幅未完成的艺术品。

然而,随着时间的推移,小白的头部逐渐丰富起来。它学会了表情和情感,曲线逐渐变得更加细腻,仿佛一个真正的人类头部。小白的眼睛是最吸引人的地方,它们不仅能够传达情感,还能够扫描和分析周围的环境…

哈哈,言归正传,要实现上面的效果,其实是以下的技术实现思路:

  1. 四段三次贝塞尔曲线

    使用四段三次 Bézier 曲线拟合圆 这篇博客里面,介绍了通过四段三次贝塞尔曲线去绘制一个圆,不止是圆,定义四段三次贝塞尔曲线能形成很多的形状。

  2. 形状组合和表情设计

    定义一个表示形状的数据结构,其中包含四段贝塞尔曲线的描述,将多个形状组合成一个表情。通过组合不同的形状来设计表情,每个形状代表表情的一部分,例如眼睛、嘴巴、脸部等,然后在画布上绘制这些部分。

  3. 动作设计

    定义一个机器人的数据结构,头部加入表情,机器人的其它部分和表情共同组成一个动作。

  4. 动作串联

    将多个动作串联实现动画效果。可以通过在时间上连续绘制不同的动作,设计一个动画系统,根据时间轴逐步绘制不同的动作,从而实现机器人的动画效果。

在 Android 开发中,可以利用 Canvas 绘图功能、动画框架等来实现这种复杂的图形效果。从一段贝塞尔曲线开始,到最终的动画效果,这将是一个奇妙的旅程。

四段三次贝塞尔曲线组成基础形状

首先,通过四段三次贝塞尔曲线组成一系列形状做为基础,如下图所示:

因为需要形状的大小可以自由变化,定义了以下函数来获取一段三次贝塞尔曲线

    /**
     * 获取一段三次贝塞尔曲线,centerX/centerY 确定中心点的位置,四个控制点都基于 radius 计算的出,这样改变 radius,即可改变形状的大小
     */
    fun getCurve(
        centerX: Float, centerY: Float, radius: Float,
        anchorRatioTR1: Float, anchorAngle1: Float,
        anchorRatioTR2: Float, anchorAngle2: Float,
        contRatioTR1: Float, contAngle1: Float,
        contRatioTR2: Float, contAngle2: Float
    ): Curve {
        val curve = Curve()
        curve.anchorPoint1.x = centerX + Math.cos(Math.toRadians(anchorAngle1.toDouble())).toFloat() * radius * anchorRatioTR1
        curve.anchorPoint1.y = centerY + Math.sin(Math.toRadians(anchorAngle1.toDouble())).toFloat() * radius * anchorRatioTR1
        curve.contPoint1.x = curve.anchorPoint1.x + Math.cos(Math.toRadians(contAngle1.toDouble())).toFloat() * radius * contRatioTR1
        curve.contPoint1.y = curve.anchorPoint1.y + Math.sin(Math.toRadians(contAngle1.toDouble())).toFloat() * radius * contRatioTR1
        curve.anchorPoint2.x = centerX + Math.cos(Math.toRadians(anchorAngle2.toDouble())).toFloat() * radius * anchorRatioTR2
        curve.anchorPoint2.y = centerY + Math.sin(Math.toRadians(anchorAngle2.toDouble())).toFloat() * radius * anchorRatioTR2
        curve.contPoint2.x = curve.anchorPoint2.x + Math.cos(Math.toRadians(contAngle2.toDouble())).toFloat() * radius * contRatioTR2
        curve.contPoint2.y = curve.anchorPoint2.y + Math.sin(Math.toRadians(contAngle2.toDouble())).toFloat() * radius * contRatioTR2
        return curve
    }

定义一个数据结构描述四段贝塞尔曲线

/**
 * 四段贝塞尔曲线组成一个形状
 */
open class CurveShape {

    // 四段贝塞尔曲线之间的链接状态 (现在只考虑首尾相连的情况,不考虑交叉想连)
    var isLink = true
    var centerX = 0f
    var centerY = 0f
    //半径 做为一个基础值,所有的计算都基于这个值
    var radius = 0f
    //存储四段贝塞尔曲线
    val curveList = ArrayList<Curve>()
}

定义一个 CurveShapeView 来负责绘制形状

class CurveShapeView @kotlin.jvm.JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    // 默认颜色 透明
    private var DEFAULT_COLOR = Color.TRANSPARENT

    // View 宽高
    private var viewWidth = 0f
    private var viewHeight = 0f
    private var centerX = 0f

    // 画笔
    private val paint: Paint by lazy { Paint(Paint.ANTI_ALIAS_FLAG) }        //画笔

    // 用于颜色渐变处理
    private val argbEvaluator = ArgbEvaluator()//渐变色计算类
    private var startColor: Int = DEFAULT_COLOR
    private var endColor: Int = DEFAULT_COLOR
    private var startStrokeWidth = 0f
    private var endStrokeWidth = 0f
    private var paintCap = Paint.Cap.ROUND

    private var curProgress: Float = 0f
    private lateinit var startVisualInfo: VisualInfo
    private lateinit var endVisualInfo: VisualInfo

    // 起始时的 curveShape
    private var startCurveShape: CurveShape? = null
    // 缓存绘制过程中生成的 curveShape,以便发送切换时作为起始
    private var cacheCurCurveShape: CurveShape? = null

    init {
        debug("init()")
        initPaint()
        initStartAndEndVisualInfo()
    }

    private fun initPaint() {
        paint.isAntiAlias = true
        paint.style = Paint.Style.FILL_AND_STROKE
        paint.strokeCap = paintCap
        paint.color = DEFAULT_COLOR
    }

    private fun initStartAndEndVisualInfo() {
        startVisualInfo = VisualInfo(DrawInfo(), ViewPropertyInfo())
        endVisualInfo = VisualInfo(DrawInfo(), ViewPropertyInfo())
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        debug("onSizeChanged()")
        viewWidth = w.toFloat()
        viewHeight = h.toFloat()
        centerX = viewWidth / 2
        startStrokeWidth = centerX * 0f
        endStrokeWidth = centerX * 0f
        paint.strokeWidth = startStrokeWidth
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        drawCurveShape(canvas)
    }

    private fun drawCurveShape(canvas: Canvas) {
        // 与 changeVisualInfo 呼应,这个时候 centerX >0
        if (startCurveShape == null) {
            this.startCurveShape = startVisualInfo.drawInfo.getCurveShape(centerX)
            updatePaint(endVisualInfo.drawInfo)
            if (curProgress > 0) {
                updateViewPropertyByProgress(centerX,startVisualInfo.viewPropertyInfo, endVisualInfo.viewPropertyInfo, curProgress)
            }
        }
        if (startColor != endColor) {
            paint.color = argbEvaluator.evaluate(curProgress, startColor, endColor) as Int
        }
        if (startStrokeWidth != endStrokeWidth) {
            paint.strokeWidth = startStrokeWidth + (endStrokeWidth - startStrokeWidth) * curProgress
        }
        startCurveShape?.let {
            cacheCurCurveShape = it.getCurrentCurveShape(endVisualInfo.drawInfo.getCurveShape(centerX), curProgress)
            val paths = cacheCurCurveShape!!.getCurveShapePaths()
            for (path in paths) {
                canvas.drawPath(path, paint)
            }
        }
    }

    /**
     * 切换
     */
    fun changeVisualInfo(visualInfo: VisualInfo, isInvalidate: Boolean = true, progress: Float = 0f) {
        this.curProgress = progress
        this.startVisualInfo = getCurrentVisualInfo()
        this.endVisualInfo = visualInfo
        debug("changeVisualInfo()")
        // 如果 changeVisualInfo 时,还没有进行测量,直接返回,延迟到 onDraw 里面执行切换后的逻辑
        if (centerX <= 0) {
            return
        }
        if (cacheCurCurveShape != null) {
            this.startCurveShape = cacheCurCurveShape
        } else {
            this.startCurveShape = startVisualInfo.drawInfo.getCurveShape(centerX)
        }
        if (curProgress > 0) {
            updateViewPropertyByProgress(centerX,startVisualInfo.viewPropertyInfo, endVisualInfo.viewPropertyInfo, curProgress)
        }
        updatePaint(endVisualInfo.drawInfo)
        if (isInvalidate) {
            invalidate()
        }
    }

    fun setProgress(progress: Float) {
        curProgress = progress
        updateViewPropertyByProgress(centerX,startVisualInfo.viewPropertyInfo, endVisualInfo.viewPropertyInfo, curProgress)
        invalidate()
    }

    fun updateStrokeWidthRatio(endStrokeWidthRatio: Float, startStrokeWidthRatio: Float = 0f) {
        this.endStrokeWidth = centerX * endStrokeWidthRatio
        this.startStrokeWidth = centerX * startStrokeWidthRatio
        invalidate()
    }

    /**
     * 是否需要更新画笔
     */
    private fun isNadeUpdatePaint(orderInfo: DrawInfo): Boolean {
        var isNadeUpdatePaint = false
        startStrokeWidth = paint.getStrokeWidth()
        endStrokeWidth = centerX * orderInfo.paintStrokeWidthRatio
        if (startStrokeWidth != endStrokeWidth) {
            isNadeUpdatePaint = true
        }
        startColor = paint.getColor()
        endColor = orderInfo.targetColor
        if (startColor != endColor) {
            isNadeUpdatePaint = true
        }
        if (paint.style != orderInfo.paintSytle) {
            paint.style = orderInfo.paintSytle
            isNadeUpdatePaint = true
        }
        if (paint.strokeCap != orderInfo.paintCap) {
            isNadeUpdatePaint = true
            paint.strokeCap = orderInfo.paintCap
        }
        return isNadeUpdatePaint
    }

    /**
     * 根据切换到的状态更新画笔
     */
    private fun updatePaint(targetDrawInfo: DrawInfo) {
        startStrokeWidth = paint.getStrokeWidth()
        endStrokeWidth = centerX * targetDrawInfo.paintStrokeWidthRatio
        startColor = paint.getColor()
        endColor = targetDrawInfo.targetColor
        paint.style = targetDrawInfo.paintSytle
        paint.strokeCap = targetDrawInfo.paintCap
    }

    /**
     * 获取当前的 visualInfo
     */
    fun getCurrentVisualInfo(): VisualInfo {
        var viewPropertyInfo = getCurrentViewPropertyInfo()
        var drawInfo: DrawInfo = getCurrentDrawInfo()
        val visualInfo = VisualInfo(drawInfo, viewPropertyInfo);
        return visualInfo
    }

    private fun getCurrentViewPropertyInfo(): ViewPropertyInfo {
        if (centerX <= 0f) {
            return return endVisualInfo.viewPropertyInfo
        }
        return this.getCurrentViewPropertyInfo(this.centerX)
    }

    private fun getCurrentDrawInfo(): DrawInfo {
        var drawInfo = DrawInfo()
        drawInfo.shapeType = endVisualInfo.drawInfo.shapeType
        drawInfo.isLink = endVisualInfo.drawInfo.isLink
        drawInfo.radiusRatio = endVisualInfo.drawInfo.radiusRatio
        drawInfo.targetColor = paint.color
        drawInfo.centerXRatio = endVisualInfo.drawInfo.centerXRatio
        drawInfo.centerYRatio = endVisualInfo.drawInfo.centerYRatio
        drawInfo.paintStrokeWidthRatio = endVisualInfo.drawInfo.paintStrokeWidthRatio
        drawInfo.paintSytle = endVisualInfo.drawInfo.paintSytle
        drawInfo.paintCap = endVisualInfo.drawInfo.paintCap
        return drawInfo
    }

    // 动画执行
    private var actionValueAnimator = ValueAnimator.ofFloat(0f, 1f)
    fun execAnim(visualInfos: ArrayList<VisualInfo>, callback: AnimExecCallback? = null) {
        if (visualInfos.isNullOrEmpty()) {
            return
        }
        changeVisualInfo(visualInfos[0])
        cancelAnim()
        actionValueAnimator.setDuration(visualInfos[0].duration)
        val listener = object : MultiAnimatorListener {
            override fun onAnimationUpdate(valueAnimator: ValueAnimator) {
                if (!visualInfos[0].isDelay) {
                    setProgress(valueAnimator.animatedValue as Float)
                }
            }
            override fun onAnimationEnd(animation: Animator) {
                if (!visualInfos[0].isDelay) {
                    setProgress(1f)
                }
                visualInfos.removeAt(0)
                if (visualInfos.isEmpty()) {
                    callback?.onAnimationAllFinish()
                } else {
                    execAnim(visualInfos, callback)
                }
            }
        }
        actionValueAnimator.addUpdateListener(listener)
        actionValueAnimator.addListener(listener)
        actionValueAnimator.start()
    }

    fun cancelAnim() {
        actionValueAnimator.removeAllUpdateListeners()
        actionValueAnimator.removeAllListeners()
        actionValueAnimator.cancel()
    }
}

下图演示从一个形状切换到另一个形状,并基于进度控制切换状态。

Screen_recording_20240320_235834.gif

因为要做状态保存,定义一个 VisualInfo 类,其中 DrawInfo 用来控制绘制(作用于 View#onDraw()),ViewPropertyInfo 用于控制 View 本身属性(供 Android 属性动画调度)

/**
 * 协调 CurveShapeView 的位置和绘制等
 */
open class VisualInfo {
    var duration: Long = Constants.DEFAULT_DURATION
    var interpolatorType: Int = 8
    var isDelay: Boolean = false
    var drawInfo = DrawInfo()
    var viewPropertyInfo = ViewPropertyInfo()
    constructor()
    constructor(drawInfo: DrawInfo, viewPropertyInfo: ViewPropertyInfo) {
        this.drawInfo = drawInfo
        this.viewPropertyInfo = viewPropertyInfo
    }
}

形状组合

有了形状,就可动手把他们组合成表情了

Screen_recording_20240321_000534.gif

这里我定义了一个 EmoteInfo 类,其中包含面部、左右眼、嘴巴等形状

/**
 * 表情信息
 */
open class EmoteInfo {
    var duration: Long = Constants.DEFAULT_DURATION
    var interpolatorType: Int = 8
    var isDelay: Boolean = false
    var faceVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_FACE, ViewPropertyInfoConst.DEFAULT_RENDERINFO_FACE)
    var leftEyeVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_ORBIT, ViewPropertyInfoConst.DEFAULT_RENDERINFO_LEFT_ORBIT)
    var leftCheekVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_ORBIT, ViewPropertyInfoConst.DEFAULT_RENDERINFO_LEFT_ORBIT)
    var rightEyeVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_ORBIT, ViewPropertyInfoConst.DEFAULT_RENDERINFO_RIGHT_ORBIT)
    var rightCheekVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_ORBIT, ViewPropertyInfoConst.DEFAULT_RENDERINFO_RIGHT_ORBIT)
    var mouseVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_ORBIT, ViewPropertyInfoConst.DEFAULT_RENDERINFO_DOWN_ORBIT)
}

同样定义一个 FaceView 用来绘制表情

class FaceView @kotlin.jvm.JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : RelativeLayout(context, attrs, defStyleAttr) {

    private var _binding: LayoutFaceViewBinding? = null
    val binding get() = _binding!!

    private var curProgress: Float = 0f

    init {
        _binding = LayoutFaceViewBinding.inflate(LayoutInflater.from(context), this)
        initViews()
    }

    private fun initViews() {

    }

    fun changeEmote(emoteInfo: EmoteInfo, isInvalidate: Boolean = true, progress: Float = 0f) {
        this.curProgress = progress
        binding.cvFaceOrbit.changeVisualInfo(emoteInfo.faceVisualInfo, isInvalidate, progress)
        binding.cvLeftCheekOrbit.changeVisualInfo(emoteInfo.leftCheekVisualInfo, isInvalidate, progress)
        binding.cvLeftEyeOrbit.changeVisualInfo(emoteInfo.leftEyeVisualInfo, isInvalidate, progress)
        binding.cvRightCheekOrbit.changeVisualInfo(emoteInfo.rightCheekVisualInfo, isInvalidate, progress)
        binding.cvRightEyeOrbit.changeVisualInfo(emoteInfo.rightEyeVisualInfo, isInvalidate, progress)
        binding.cvMouseOrbit.changeVisualInfo(emoteInfo.mouseVisualInfo, isInvalidate, progress)
    }

    fun setProgress(progress: Float) {
        this.curProgress = progress
        binding.cvFaceOrbit.setProgress(progress)
        binding.cvLeftCheekOrbit.setProgress(progress)
        binding.cvLeftEyeOrbit.setProgress(progress)
        binding.cvRightCheekOrbit.setProgress(progress)
        binding.cvRightEyeOrbit.setProgress(progress)
        binding.cvMouseOrbit.setProgress(progress)
    }

    // 动画执行
    private var actionValueAnimator = ValueAnimator.ofFloat(0f, 1f)
    fun execAnim(emoteInfos: ArrayList<EmoteInfo>, callback: AnimExecCallback? = null) {
        if (emoteInfos.isNullOrEmpty()) {
            return
        }
        changeEmote(emoteInfos[0])
        cancelAnim()
        actionValueAnimator.setDuration(emoteInfos[0].duration)
        val listener = object : MultiAnimatorListener {
            override fun onAnimationUpdate(valueAnimator: ValueAnimator) {
                if (!emoteInfos[0].isDelay) {
                    setProgress(valueAnimator.animatedValue as Float)
                }
            }

            override fun onAnimationEnd(animation: Animator) {
                if (!emoteInfos[0].isDelay) {
                    setProgress(1f)
                }
                emoteInfos.removeAt(0)
                if (emoteInfos.isEmpty()) {
                    callback?.onAnimationAllFinish()
                } else {
                    execAnim(emoteInfos, callback)
                }
            }
        }
        actionValueAnimator.addUpdateListener(listener)
        actionValueAnimator.addListener(listener)
        actionValueAnimator.start()
    }

    fun cancelAnim() {
        actionValueAnimator.removeAllUpdateListeners()
        actionValueAnimator.removeAllListeners()
        actionValueAnimator.cancel()
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        cancelAnim()
        _binding = null
    }
}

下图演示从一个表情切换到另一个表情,并基于进度控制切换表情,由于基于属性,可以在任何时间切换时获取当前属性,从而是切换时画面自然过渡。

Screen_recording_20240321_005101.gif

其实到这一步,就可以通过变换表情来执行动画了,但总感觉少了点什么,所以下面底部加了个小影子

动作设计

定义一个 PoseInfo 类,代表一个动作,主要包含表情、底部的影子、头部的属性控制等

/**
 * pose (摆pose,或者理解为动画的关键帧)
 * TODO: 这里实现形式有问题,影子应该是现实意义上的“影子”,影子跟随头部移动(头部移动,影子基于头部属性跟随移动)这样就不用定义影子相关的动画执行了,设计动作时只考虑表情就好了,可以极大简化设计动作时的复制度。
 * 并且跟随动画的形式,也避免了多重动画(上下摆动和 PoseInfo 里面定义的动画)冲突的问题。过了一天才想到这个点,以后再尝试吧。
 */
open class PoseInfo {
    var duration: Long = Constants.DEFAULT_DURATION
    var interpolatorType: Int = 8
    var isDelay: Boolean = false
    var emoteInfo: EmoteInfo = EmoteInfo()
    var trayVisualInfo: VisualInfo = VisualInfo() // 跟随动画的实现方式,这块只保留大小和颜色就好了
    var robotViewPropertyInfo: ViewPropertyInfo = ViewPropertyInfo()
    var headViewPropertyInfo: ViewPropertyInfo = ViewPropertyInfo() 
    var trayContainerViewPropertyInfo: ViewPropertyInfo = ViewPropertyInfo()// 跟随动画的实现方式,这块就不需要了
}

同样定义一个 RobotView 负责展示和执行动作

class RobotView @kotlin.jvm.JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : RelativeLayout(context, attrs, defStyleAttr) {

    private var _binding: LayoutRobotViewBinding? = null
    private val binding get() = _binding!!

    private var centerX = 0f

    private var curProgress: Float = 0f
    private lateinit var startPoseInfo: PoseInfo
    private lateinit var endPoseInfo: PoseInfo

    private val selfViewAnimDelegate: ViewAnimDelegate by lazy {
        ViewAnimDelegate(this, centerX)
    }

    private val headViewAnimDelegate: ViewAnimDelegate by lazy {
        ViewAnimDelegate(binding.flHeadContainer, centerX)
    }

    private val trayViewAnimDelegate: ViewAnimDelegate by lazy {
        ViewAnimDelegate(binding.flTrayContainer, centerX)
    }

    init {
        _binding = LayoutRobotViewBinding.inflate(LayoutInflater.from(context), this)
        initViews()
    }

    private fun initViews() {
        startPoseInfo = PoseInfo(EmoteInfo(), VisualInfo())
        endPoseInfo = PoseInfo(EmoteInfo(), VisualInfo())
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        centerX = w.toFloat() / 2
    }

    fun changePose(poseInfo: PoseInfo, isInvalidate: Boolean = true, progress: Float = 0f) {
        this.curProgress = progress
        binding.fvFace.changeEmote(poseInfo.emoteInfo, isInvalidate, progress)
        binding.cvTrayShape.changeVisualInfo(poseInfo.trayVisualInfo, isInvalidate, progress)
        endPoseInfo = poseInfo
        if (centerX == 0f) {
            return
        }
        startPoseInfo.robotViewPropertyInfo = this.getCurrentViewPropertyInfo(centerX)
        startPoseInfo.headViewPropertyInfo = binding.flHeadContainer.getCurrentViewPropertyInfo(centerX)
        startPoseInfo.trayContainerViewPropertyInfo = binding.flTrayContainer.getCurrentViewPropertyInfo(centerX)
        this.updateViewPropertyByProgress(centerX, startPoseInfo.robotViewPropertyInfo, poseInfo.robotViewPropertyInfo, progress)
        binding.flHeadContainer.updateViewPropertyByProgress(
            centerX,
            startPoseInfo.headViewPropertyInfo,
            poseInfo.headViewPropertyInfo,
            progress
        )
        binding.flTrayContainer.updateViewPropertyByProgress(
            centerX,
            startPoseInfo.trayContainerViewPropertyInfo,
            poseInfo.trayContainerViewPropertyInfo,
            progress
        )
    }

    fun setProgress(progress: Float) {
        this.curProgress = progress
        binding.fvFace.setProgress(progress)
        binding.cvTrayShape.setProgress(progress)
        this.updateViewPropertyByProgress(centerX, startPoseInfo.robotViewPropertyInfo, endPoseInfo.robotViewPropertyInfo, progress)
        binding.flHeadContainer.updateViewPropertyByProgress(
            centerX,
            startPoseInfo.headViewPropertyInfo,
            endPoseInfo.headViewPropertyInfo,
            progress
        )
        binding.flTrayContainer.updateViewPropertyByProgress(
            centerX,
            startPoseInfo.trayContainerViewPropertyInfo,
            endPoseInfo.trayContainerViewPropertyInfo,
            progress
        )
    }
    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        cancelActionAnim()
        _binding = null
    }
}

动作串联

在 RobotView 定义了动画执行控制器,其实就是一个属性动画

 /**
	    * 整体一起执行动画,依次执行设计好的动作
	    */
    private var actionValueAnimator = ValueAnimator.ofFloat(0f, 1f)
    fun execAction(posts: ArrayList<PoseInfo>, callback: AnimExecCallback? = null) {
        if (posts.isNullOrEmpty()) {
            return
        }
        changePose(posts[0])
        cancelActionAnim()
        actionValueAnimator.setDuration(posts[0].duration)
        val listener = object : MultiAnimatorListener {
            override fun onAnimationUpdate(valueAnimator: ValueAnimator) {
                if (!posts[0].isDelay) {
                    setProgress(valueAnimator.animatedValue as Float)
                }
            }

            override fun onAnimationEnd(animation: Animator) {
                if (!posts[0].isDelay) {
                    setProgress(1f)
                }
                posts.removeAt(0)
                if (posts.isEmpty()) {
                    callback?.onAnimationAllFinish()
                } else {
                    execAction(posts, callback)
                }
            }
        }
        actionValueAnimator.addUpdateListener(listener)
        actionValueAnimator.addListener(listener)
        actionValueAnimator.start()
    }

    private fun cancelActionAnim() {
        actionValueAnimator.removeAllUpdateListeners()
        actionValueAnimator.removeAllListeners()
        actionValueAnimator.cancel()
    }

    fun execEmotesAnim(
        emotes: ArrayList<EmoteInfo>,
        emotesCallback: AnimExecCallback? = null
    ) {
        binding.fvFace.execAnim(emotes, emotesCallback)
    }

考虑到机器人外部执行动画的同时,做表情变化(比如演示中上下摆动的同时做表情变换),在 FaceView 里面同样定义了动画执行控制器。

至此,按照开始时的思路已一一实现。

做这个小机器人有什么用呢

除了拿来写博客,它还有别的用处吗?

  1. 可以设计一系列表情,做矢量图形使用。
  2. 人脸验证的时候,提示张嘴、闭眼,可以用表情动画做对应的提示。
  3. 多设计几组动作,结合 ChatGpt Api 做一个聊天机器人应该也挺有意思的吧。

相关资料

代码链接

使用四段三次 Bézier 曲线拟合圆

后记:看起来简单,真正做的过程中还是充满了各种挑战,坚持到写完博客真的是心力憔悴。 这份代码虽然还有很多不足的地方(对象频繁创建,整个代码结构可以使用设计模式做规整,捏和图形像是徒手磨螺丝,机器人整体动画实现方式不合理),休息一段时间之后再回来做优化吧。

围绕表情做抽象,结合 processing 里面的音乐可视化、分形等元素,只做 Ai 交互的机器人形象部分,在手表、迷你显示器之类的上面或许有一定的应用空间。