65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (97投票s)

2010 年 9 月 18 日

CPOL

6分钟阅读

viewsIcon

243750

downloadIcon

2139

了解如何使用简单的 WPF 或 Silverlight 元素创建复杂的火柴人动画。

StickFigure

目录

引言

最近,我想在 WPF 或 Silverlight 中创建一种火柴人动画,但我找不到任何相关的资料。我感到很沮丧,然后决定自己动手做,幸运的是我成功了。本文详细介绍如何在 WPF 中创建火柴人动画,尽管我相信它可以轻松移植到 Silverlight。本文旨在与读者分享一些有趣的发现。

系统要求

如果您已经拥有 Visual Studio 2008 或 Visual Studio 2010,这足以运行该应用程序。如果您没有,您可以直接从 Microsoft 下载以下 100% 免费的开发工具。

背景

有一段时间,我想知道如何使用 WPF 或 Silverlight 创建一个枢轴火柴人动画。在第一次尝试中,我使用了在画布上独立移动的火柴。但是,我没有使用标准的 WPF Animation 类,而是不得不自己控制动画,使用计时器来更新每个单独火柴的角度和位置,同时还要考虑旋转速度。由于火柴人是一个关节系统,当一个肢体旋转时,相连的肢体必须相应地旋转。例如,如果我旋转一条腿,我还必须相应地旋转小腿,并根据膝盖的新位置重新计算小腿的坐标。这确实是一项繁琐的任务。

经过大量的代码调试,最终效果不错,但我意识到我最终创建了一个小怪物,一个真正的代码灾难,然后我决定抛弃它并从头开始。

其思想是,在任何关节体中,我可以选择该体的某个特定部分作为整个体的“根”,然后将各个部分依次“链接”在第一个部分的边缘,创建一个链状的各个部分。好消息是,这可以在 WPF(或者您愿意的话也可以是 Silverlight)中实现,通过创建一个 Grid 元素来表示每个独立的部件,并将其他 Grid 元素作为根部分的子元素添加。Grid 元素是最强大的视觉元素,这并非没有道理。通过在 Grid 中创建三个 ColumnDefinition 来实现神奇的效果:一个 ColumnDefinition 位于部分中间,决定该部分的延伸,而另外两个位于边缘,充当子部分的**枢轴点**。

基础段

The Base Segment

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;
}

圆形段

The Circle Segment

圆形段仅用于火柴人的头部。圆形位于中心列,边缘的两个角用作枢轴点。

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);
}

轴段

The Axis Segment

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);
}

构建火柴人先生:头部和躯干

Head And Trunk

头部是火柴人的第一部分。然后将躯干作为子元素添加,位于头部段的第三列。请注意,由于列是水平放置的,我们需要将头部旋转 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);
}

头部、躯干和手臂

Head, Trunk And Arms

手臂必须放置在火柴人的肩部;也就是说,手臂是躯干段的子元素,位于躯干顶部的**枢轴点 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);
}

全身

The Whole Body

现在我们有了所有的身体部分。部分层次结构由下面的树状列表定义。

  • 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:初始版本。
© . All rights reserved.