使用 C# 和 WPF 的简笔画动画






4.97/5 (97投票s)
了解如何使用简单的 WPF 或 Silverlight 元素创建复杂的火柴人动画。
目录
引言
最近,我想在 WPF 或 Silverlight 中创建一种火柴人动画,但我找不到任何相关的资料。我感到很沮丧,然后决定自己动手做,幸运的是我成功了。本文详细介绍如何在 WPF 中创建火柴人动画,尽管我相信它可以轻松移植到 Silverlight。本文旨在与读者分享一些有趣的发现。
系统要求
如果您已经拥有 Visual Studio 2008 或 Visual Studio 2010,这足以运行该应用程序。如果您没有,您可以直接从 Microsoft 下载以下 100% 免费的开发工具。
背景
有一段时间,我想知道如何使用 WPF 或 Silverlight 创建一个枢轴火柴人动画。在第一次尝试中,我使用了在画布上独立移动的火柴。但是,我没有使用标准的 WPF Animation
类,而是不得不自己控制动画,使用计时器来更新每个单独火柴的角度和位置,同时还要考虑旋转速度。由于火柴人是一个关节系统,当一个肢体旋转时,相连的肢体必须相应地旋转。例如,如果我旋转一条腿,我还必须相应地旋转小腿,并根据膝盖的新位置重新计算小腿的坐标。这确实是一项繁琐的任务。
经过大量的代码调试,最终效果不错,但我意识到我最终创建了一个小怪物,一个真正的代码灾难,然后我决定抛弃它并从头开始。
其思想是,在任何关节体中,我可以选择该体的某个特定部分作为整个体的“根”,然后将各个部分依次“链接”在第一个部分的边缘,创建一个链状的各个部分。好消息是,这可以在 WPF(或者您愿意的话也可以是 Silverlight)中实现,通过创建一个 Grid
元素来表示每个独立的部件,并将其他 Grid
元素作为根部分的子元素添加。Grid
元素是最强大的视觉元素,这并非没有道理。通过在 Grid
中创建三个 ColumnDefinition
来实现神奇的效果:一个 ColumnDefinition
位于部分中间,决定该部分的延伸,而另外两个位于边缘,充当子部分的**枢轴点**。
基础段
BaseSegment
是我们从中派生其他段类的抽象类。请注意,它没有任何外观。相反,它只定义了上面图表中看到的三个网格列。
protected virtual void InitializeSegment()
{
this.ShowGridLines = false;
this.HorizontalAlignment = System.Windows.HorizontalAlignment.Left;
this.ColumnDefinitions.Add(new ColumnDefinition() {
Width = GridLength.Auto, MinWidth = segmentWidth });
this.ColumnDefinitions.Add(new ColumnDefinition() {
Width = new GridLength(segmentLength) });
this.ColumnDefinitions.Add(new ColumnDefinition() {
Width = GridLength.Auto, MinWidth = segmentWidth });
st = new ScaleTransform()
{
};
tt = new TranslateTransform()
{
X = 0,
Y = 0
};
rt = new RotateTransform()
{
CenterX = segmentWidth,
CenterY = segmentWidth
};
TransformGroup tGroup = new TransformGroup();
tGroup.Children.Add(st);
tGroup.Children.Add(rt);
tGroup.Children.Add(tt);
this.RenderTransform = tGroup;
}
圆形段
圆形段仅用于火柴人的头部。圆形位于中心列,边缘的两个角用作枢轴点。
protected override void InitializeSkin()
{
this.ColumnDefinitions[1].Width = new GridLength(segmentLength * 2);
this.Height = segmentLength * 2;
Rectangle rect = new Rectangle()
{
Stroke = new SolidColorBrush(Colors.White),
StrokeThickness = 0.5,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
RadiusX = segmentWidth * 2,
RadiusY = segmentWidth * 2
};
rect.SetValue(Grid.ColumnProperty, 1);
rect.SetValue(Panel.ZIndexProperty, 1);
this.Children.Add(rect);
}
轴段
AxisSegment
在我们火柴人的几乎所有部分中都使用。下面的代码显示了轴段的“皮肤”由一个跨越 BaseSegment
元素三个列的圆角矩形定义。
protected override void InitializeSkin()
{
rect = new Rectangle()
{
Stroke = new SolidColorBrush(Colors.White),
StrokeThickness = 0.5,
Width = segmentLength * 2,
MaxWidth = segmentLength * 2,
Height = segmentWidth * 2,
RadiusX = segmentWidth,
RadiusY = segmentWidth,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Stretch,
Margin = new Thickness(0, 0, 0, 0)
};
rect.SetValue(Grid.ColumnProperty, 0);
rect.SetValue(Grid.ColumnSpanProperty, 3);
Rectangle dash1 = new Rectangle()
{
Stroke = new SolidColorBrush(Colors.Red),
StrokeDashArray = new DoubleCollection(new double[]{4,4}),
StrokeThickness = 1,
Width = 1,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Stretch
};
Rectangle dash2 = new Rectangle()
{
Stroke = new SolidColorBrush(Colors.Green),
StrokeDashArray =
new DoubleCollection(new double[] { 2, 2 }.ToList()),
StrokeThickness = 1,
Width = 1,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Stretch
};
dash1.SetValue(Grid.ColumnProperty, 1);
dash2.SetValue(Grid.ColumnProperty, 1);
this.Children.Add(dash1);
this.Children.Add(dash2);
this.Children.Add(rect);
}
构建火柴人先生:头部和躯干
头部是火柴人的第一部分。然后将躯干作为子元素添加,位于头部段的第三列。请注意,由于列是水平放置的,我们需要将头部旋转 90 度,以便身体可以垂直站立。头部长度为 10,躯干长度为 20。躯干连接到头部段底部的**枢轴点 P2**。
private void CreateStickFigure()
{
...
head = new CircleSegment(10);
head.TT.X = currentPoint.X;
head.TT.Y = currentPoint.Y;
head.RT.Angle = 90;
head.VerticalAlignment = VerticalAlignment.Top;
head.HorizontalAlignment = HorizontalAlignment.Left;
trunk = new AxisSegment(20);
...
head.AddChildElement(trunk, PivotPoint.P2, Layer.ForeGround);
...
this.Children.Add(head);
}
头部、躯干和手臂
手臂必须放置在火柴人的肩部;也就是说,手臂是躯干段的子元素,位于躯干顶部的**枢轴点 P1**,也就是网格的第一列。
private void CreateStickFigure()
{
...
head = new CircleSegment(10);
head.TT.X = currentPoint.X;
head.TT.Y = currentPoint.Y;
head.RT.Angle = 90;
head.VerticalAlignment = VerticalAlignment.Top;
head.HorizontalAlignment = HorizontalAlignment.Left;
trunk = new AxisSegment(20);
...
arm1 = new AxisSegment(12);
arm2 = new AxisSegment(12);
...
trunk.AddChildElement(arm1, PivotPoint.P1, Layer.BackGround);
trunk.AddChildElement(arm2, PivotPoint.P1, Layer.ForeGround);
head.AddChildElement(trunk, PivotPoint.P2, Layer.ForeGround);
...
this.Children.Add(head);
}
全身
现在我们有了所有的身体部分。部分层次结构由下面的树状列表定义。
- Head
- 主干
- 左臂
- 左前臂
- 右臂
- 右前臂
- 左腿
- 左小腿
- 右腿
- 右小腿
这是构建火柴人先生所有身体部分的 C# 代码。
private void CreateStickFigure()
{
...
head = new CircleSegment(10);
head.TT.X = currentPoint.X;
head.TT.Y = currentPoint.Y;
head.RT.Angle = 90;
head.VerticalAlignment = VerticalAlignment.Top;
head.HorizontalAlignment = HorizontalAlignment.Left;
trunk = new AxisSegment(20);
leg1 = new AxisSegment(15);
leg1.Margin = new Thickness(5, 0, 0, 0);
leg2 = new AxisSegment(15);
leg2.Margin = new Thickness(5, 0, 0, 0);
foreleg1 = new AxisSegment(15);
foreleg2 = new AxisSegment(15);
arm1 = new AxisSegment(12);
arm2 = new AxisSegment(12);
forearm1 = new AxisSegment(12);
forearm2 = new AxisSegment(12);
...
leg1.AddChildElement(foreleg1, PivotPoint.P2, Layer.BackGround);
leg2.AddChildElement(foreleg2, PivotPoint.P2, Layer.ForeGround);
arm1.AddChildElement(forearm1, PivotPoint.P2, Layer.BackGround);
arm2.AddChildElement(forearm2, PivotPoint.P2, Layer.ForeGround);
trunk.AddChildElement(leg1, PivotPoint.P2, Layer.BackGround);
trunk.AddChildElement(leg2, PivotPoint.P2, Layer.ForeGround);
trunk.AddChildElement(arm1, PivotPoint.P1, Layer.BackGround);
trunk.AddChildElement(arm2, PivotPoint.P1, Layer.ForeGround);
head.AddChildElement(trunk, PivotPoint.P2, Layer.ForeGround);
...
this.Children.Add(head);
}
设置链式动画
这是我们火柴人动画的核心。SetAngleAnimations
方法存在于 BaseSegment
类中,它定义了一个从给定的预定义角度数组中创建的动画序列。您所要做的就是将动画的键名、角度数组(这将成为每个火柴人肢体的起始角度和结束角度)以及动画是否连续传递给该方法。请注意,对于给定的 N 个角度数组,该方法不仅创建 N - 1 个动画,还实现了每个动画的 Completed
事件,以便在任何动画完成后,另一个动画将开始。
public void SetAngleAnimations(string key, int[] angles, bool repeatForever)
{
List<DoubleAnimation> angleAnimationList;
if (!angleAnimationDictionary.ContainsKey(key))
{
angleAnimationList = new List<DoubleAnimation>();
angleAnimationDictionary.Add(key, angleAnimationList);
}
else
{
angleAnimationList = angleAnimationDictionary[key];
}
angleAnimationList.Clear();
for (int i = 0; i < angles.Length - 1; i++)
{
DoubleAnimation da = new DoubleAnimation()
{
Name = "da" + i.ToString(),
Duration =
new Duration(new TimeSpan(0, 0, 0, 0, minAnimationDuration))
};
angleAnimationList.Add(da);
}
for (int i = 0; i < angleAnimationList.Count; i++)
{
angleAnimationList[i].From = angles[i];
angleAnimationList[i].To = angles[i + 1];
if (i < angleAnimationList.Count - 1)
{
angleAnimationList[i].Completed += (sender, e) =>
{
var clock = sender as AnimationClock;
var animation = clock.Timeline as DoubleAnimation;
int nextIndex = Convert.ToInt32(
(animation.Name.Replace("da", ""))) + 1;
this.BeginAngleAnimation(angleAnimationList[nextIndex]);
};
}
else
{
if (repeatForever)
{
angleAnimationList[i].Completed += (sender, e) =>
{
var clock = sender as AnimationClock;
var animation = clock.Timeline as DoubleAnimation;
int nextIndex = 0;
this.BeginAngleAnimation(angleAnimationList[nextIndex]);
};
}
}
}
}
话虽如此,让我们看看 SetupAngleAnimations
方法,它定义了火柴人先生身体每个部分的动画。
private void SetupAngleAnimations()
{
leg1.SetAngleAnimations("walkToEast",
new int[] { MAX_LEG_ANGLE_WALK, MAX_LEG_ANGLE_WALK, 0,
-MAX_LEG_ANGLE_WALK, 0 }, false);
leg2.SetAngleAnimations("walkToEast",
new int[] { -MAX_LEG_ANGLE_WALK, -MAX_LEG_ANGLE_WALK, 0,
MAX_LEG_ANGLE_WALK, 0 }, false);
foreleg1.SetAngleAnimations("walkToEast",
new int[] { MIN_FORELEG_ANGLE_WALK, MAX_FORELEG_ANGLE_WALK, 0,
MIN_FORELEG_ANGLE_WALK, 0 }, false);
foreleg2.SetAngleAnimations("walkToEast",
new int[] { MAX_FORELEG_ANGLE_WALK, MIN_FORELEG_ANGLE_WALK, 0,
MAX_FORELEG_ANGLE_WALK, 0 }, false);
arm1.SetAngleAnimations("walkToEast",
new int[] { 0, -MAX_ARM_ANGLE_WALK, 0, MAX_ARM_ANGLE_WALK, 0 }, false);
arm2.SetAngleAnimations("walkToEast",
new int[] { 0, MAX_ARM_ANGLE_WALK, 0, -MAX_ARM_ANGLE_WALK, 0 }, false);
forearm1.SetAngleAnimations("walkToEast",
new int[] { 0, MIN_FOREARM_ANGLE_WALK, 0, MAX_FOREARM_ANGLE_WALK, 0 }, false);
forearm2.SetAngleAnimations("walkToEast",
new int[] { 0, -MIN_FOREARM_ANGLE_WALK, 0, -MAX_FOREARM_ANGLE_WALK, 0 }, false);
leg1.SetAngleAnimations("walkToWest",
new int[] { -MAX_LEG_ANGLE_WALK, -MAX_LEG_ANGLE_WALK, 0,
MAX_LEG_ANGLE_WALK, 0 }, false);
leg2.SetAngleAnimations("walkToWest",
new int[] { MAX_LEG_ANGLE_WALK, MAX_LEG_ANGLE_WALK, 0,
-MAX_LEG_ANGLE_WALK, 0 }, false);
foreleg1.SetAngleAnimations("walkToWest",
new int[] { -MIN_FORELEG_ANGLE_WALK, -MAX_FORELEG_ANGLE_WALK, 0,
-MIN_FORELEG_ANGLE_WALK, 0 }, false);
foreleg2.SetAngleAnimations("walkToWest",
new int[] { -MAX_FORELEG_ANGLE_WALK, -MIN_FORELEG_ANGLE_WALK, 0,
-MAX_FORELEG_ANGLE_WALK, 0 }, false);
arm1.SetAngleAnimations("walkToWest",
new int[] { 0, MAX_ARM_ANGLE_WALK, 0, -MAX_ARM_ANGLE_WALK, 0 }, false);
arm2.SetAngleAnimations("walkToWest",
new int[] { 0, -MAX_ARM_ANGLE_WALK, 0, MAX_ARM_ANGLE_WALK, 0 }, false);
forearm1.SetAngleAnimations("walkToEast",
new int[] { 0, -MIN_FOREARM_ANGLE_WALK, 0, -MAX_FOREARM_ANGLE_WALK, 0 }, false);
forearm2.SetAngleAnimations("walkToEast",
new int[] { 0, MIN_FOREARM_ANGLE_WALK, 0, MAX_FOREARM_ANGLE_WALK, 0 }, false);
leg2.SetAngleAnimations("kickToEast", new int[] { -15, -45, -90, -15, 0 }, false);
foreleg2.SetAngleAnimations("kickToEast", new int[] { 0, 90, 15, 15, 0 }, false);
arm1.SetAngleAnimations("kickToEast", new int[] { 0, 0, 0, 0, 0 }, false);
arm2.SetAngleAnimations("kickToEast", new int[] { 0, MAX_ARM_ANGLE_WALK / 2,
MAX_ARM_ANGLE_WALK, MAX_ARM_ANGLE_WALK / 2, 0 }, false);
forearm1.SetAngleAnimations("kickToEast",
new int[] { 0, -MAX_FOREARM_ANGLE_WALK * 2, -MAX_FOREARM_ANGLE_WALK * 2,
-MAX_FOREARM_ANGLE_WALK * 2, 0 }, false);
forearm2.SetAngleAnimations("kickToEast",
new int[] { 0, MIN_FOREARM_ANGLE_WALK * 2, MIN_FOREARM_ANGLE_WALK * 2,
MIN_FOREARM_ANGLE_WALK * 2, 0 }, false);
leg2.SetAngleAnimations("kickToWest", new int[] { 0, 0, 90, 15, 0 }, false);
foreleg2.SetAngleAnimations("kickToWest", new int[] { 0, -90, -15, -15, 0 }, false);
arm1.SetAngleAnimations("kickToWest", new int[] { 0, 0, 0, 0, 0 }, false);
arm2.SetAngleAnimations("kickToWest",
new int[] { 0, -MAX_ARM_ANGLE_WALK / 2, -MAX_ARM_ANGLE_WALK,
-MAX_ARM_ANGLE_WALK / 2, 0 }, false);
forearm1.SetAngleAnimations("kickToWest",
new int[] { 0, MAX_FOREARM_ANGLE_WALK * 2, MAX_FOREARM_ANGLE_WALK * 2,
MAX_FOREARM_ANGLE_WALK * 2, 0 }, false);
forearm2.SetAngleAnimations("kickToWest",
new int[] { 0, -MIN_FOREARM_ANGLE_WALK * 2, -MIN_FOREARM_ANGLE_WALK * 2,
-MIN_FOREARM_ANGLE_WALK * 2, 0 }, false);
}
请注意这里所做的事情:SetAngleAnimations
方法为我们完成了所有枯燥乏味的旋转动画定义工作,并将这些动画与相应的火柴人部件连接起来。此外,您可以通过向传递给 SetAngleAnimations
方法的 int[]
数组添加更多元素来创建更多动画。
这项技术的优点是您不再需要独立旋转或移动每个部分——当您移动或旋转一个“父”部分(在部件层次结构中)时,所有子部分都会自动移动或旋转!这样,屏幕上就不再是一堆零散的部件,而是一个更连贯的“身体”。尽管这是一个火柴人动画,但它可以很容易地修改以创建结构化的身体动画,例如风车、摩天轮、机械臂、机械引擎等——天空是极限!
因此,在设置好类之后,火柴人先生就可以轻松地行走和踢腿,而且几乎不费吹灰之力。我相信您可以添加更有趣的动作,比如跑步,甚至像查克·诺里斯一样做一个“回旋踢”。
最终思考
感谢您耐心阅读本文,我很想听听您对本文提出概念的看法。我相信还有一些可以改进的地方,所以请提供您的反馈,特别是如果本文在某种程度上对您有所帮助。
历史
- 2010-09-18:初始版本。