效果预览
可以先思考一下,如何使用代码实现上面的效果呢?
实现思路
开始之前,还是要从一个故事讲起,在一个遥远的未来世界,有一个独特的机器人,它的名字叫做小白,小白不像传统的机器人那样呆板,而是由流畅的曲线和精巧的结构构成。它的头部,特别引人注目。开始的时候,它的头部只是一个简单的贝塞尔曲线构成的轮廓,仿佛是一幅未完成的艺术品。
然而,随着时间的推移,小白的头部逐渐丰富起来。它学会了表情和情感,曲线逐渐变得更加细腻,仿佛一个真正的人类头部。小白的眼睛是最吸引人的地方,它们不仅能够传达情感,还能够扫描和分析周围的环境…
哈哈,言归正传,要实现上面的效果,其实是以下的技术实现思路:
-
四段三次贝塞尔曲线
在 使用四段三次 Bézier 曲线拟合圆 这篇博客里面,介绍了通过四段三次贝塞尔曲线去绘制一个圆,不止是圆,定义四段三次贝塞尔曲线能形成很多的形状。
-
形状组合和表情设计
定义一个表示形状的数据结构,其中包含四段贝塞尔曲线的描述,将多个形状组合成一个表情。通过组合不同的形状来设计表情,每个形状代表表情的一部分,例如眼睛、嘴巴、脸部等,然后在画布上绘制这些部分。
-
动作设计
定义一个机器人的数据结构,头部加入表情,机器人的其它部分和表情共同组成一个动作。
-
动作串联
将多个动作串联实现动画效果。可以通过在时间上连续绘制不同的动作,设计一个动画系统,根据时间轴逐步绘制不同的动作,从而实现机器人的动画效果。
在 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()
}
}
下图演示从一个形状切换到另一个形状,并基于进度控制切换状态。
因为要做状态保存,定义一个 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
}
}
形状组合
有了形状,就可动手把他们组合成表情了
这里我定义了一个 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
}
}
下图演示从一个表情切换到另一个表情,并基于进度控制切换表情,由于基于属性,可以在任何时间切换时获取当前属性,从而是切换时画面自然过渡。
其实到这一步,就可以通过变换表情来执行动画了,但总感觉少了点什么,所以下面底部加了个小影子
动作设计
定义一个 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 里面同样定义了动画执行控制器。
至此,按照开始时的思路已一一实现。
做这个小机器人有什么用呢
除了拿来写博客,它还有别的用处吗?
- 可以设计一系列表情,做矢量图形使用。
- 人脸验证的时候,提示张嘴、闭眼,可以用表情动画做对应的提示。
- 多设计几组动作,结合 ChatGpt Api 做一个聊天机器人应该也挺有意思的吧。
相关资料
后记:看起来简单,真正做的过程中还是充满了各种挑战,坚持到写完博客真的是心力憔悴。 这份代码虽然还有很多不足的地方(对象频繁创建,整个代码结构可以使用设计模式做规整,捏和图形像是徒手磨螺丝,机器人整体动画实现方式不合理),休息一段时间之后再回来做优化吧。
围绕表情做抽象,结合 processing 里面的音乐可视化、分形等元素,只做 Ai 交互的机器人形象部分,在手表、迷你显示器之类的上面或许有一定的应用空间。