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

Magnet:3D 益智游戏

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (29投票s)

2013年12月17日

Ms-RL

8分钟阅读

viewsIcon

55708

downloadIcon

2786

你能解开这个吗?

引言

这项作品融合了数学和物理的基本原理。这款应用程序是三维空间中的一个脑筋急转弯,其理念是使用一个黄色的立方体(也称为磁铁)在 3D 网格中向左/右、前/后、上/下方向推动立方体,并形成图 1 左侧所示的图案。一旦图案形成,图案就会消失,游戏结束。

背景

这是一个视频演示

Using the Code

图 1

上图是应用程序的快照,使用 MVVM 模式用 WPF 编写。代码使用了相机、灯光(点、方向)、自定义几何体(modelvisual3d)、delegatecommand、自定义触发器操作、依赖属性等等。

我将以问答的形式来阐述这篇文章(解释代码的作用以及提供代码片段)。

什么是透视相机、位置、视线方向、视野角?

透视相机以更真实的方式将 3D 对象投影到 2D 表面上。

  • Position 告诉我们相机在 3D 空间中的位置,默认值为 Point3D(00, 125, 855)
  • Look-direction 是一个 3DVector(幅度和方向),告诉我们从相机位置看向何处,默认值为 Vector3D(0, -125, -855)

  • FieldofView 是一个角度,定义了 3D 视图中对象的放大程度。下图显示了由相同颜色的线条形成的不同的视野角,默认值为 70,这就像缩放功能一样,增加 fieldofview 会导致放大,减小 fieldofview 会导致缩小。

要制作什么图案以及移动磁铁的控件?

通过移动磁铁并向任何方向推动其他立方体来构建上图所示的图案。磁铁激活时,会将其视线方向上的其他立方体吸引到自己身边。第二张图显示了移动磁铁的键盘按键,按空格键/回车键激活磁铁,使用 PageUp/PageDown 键向前和向后移动,其他按键为(f - 左,g - 前,v - 下,t - 上,h - 右,y - 后,空格 - 磁铁)。

  private Model3DGroup TargetFigure();  

如何构建网格?

网格是由线条组成的网络,连接在一起形成一个带有空块的结构,磁铁在这些空块中移动,这些线条实际上是一个半径为 1 的圆柱体,Mesh.cs 负责创建、旋转、平移和定位线条/圆柱体以形成 3D 网格。

Mesh.cs 中的示例代码创建了一个 cylinder3d

Cylinder3D cylinder = new Cylinder3D();
cylinder.Length = Math.Abs((Constants.NoofBlocksInXdirection) * cubeLength + 4);
cylinder.Radius = cylinderRadius;
cylinder.Material = new DiffuseMaterial(colorBrush);
cylinder.BackMaterial = new DiffuseMaterial(colorBrush);
 
TranslateTransform3D Transalte = new TranslateTransform3D();
Transalte.OffsetX = xCoordinate + 2;
Transalte.OffsetY = yCoordinate + cubeLength * levels;
Transalte.OffsetZ = zCoordinate;
 
RotateTransform3D ROTATE = new RotateTransform3D();
Vector3D vector3d = new Vector3D(0, 0, 1);
ROTATE.Rotation = new AxisAngleRotation3D(vector3d, 90);
 
Transform3DGroup myTransformGroup = new Transform3DGroup();
myTransformGroup.Children.Add(ROTATE);
myTransformGroup.Children.Add(Transalte);
cylinder._content.Transform = myTransformGroup;
modelGroup.Children.Add(cylinder._content);

线条是如何制作的?

这是线条一部分的放大图像,它实际上是一个圆柱体,圆柱体是 MeshGeometry3D,网格几何体是位置和三角形索引的集合。

Positions 是坐标的集合,将线条长度分成若干部分,在每个部分周围创建圆(使得线条穿过圆的中心,并且线条垂直于圆的表面),将圆周分成若干部分,计算圆周上点的坐标。

在上图中,圆被分成四部分,线条被分成两部分,0,1,2,3,4,5,6,7 是位置编号(不是坐标)。下面的代码计算坐标。

 for (int i =0; i <= lengthDivision; i++)
            {
                double y = minYCoor + i * dy;
 
                for (int j = 0; j < circumferenceDivision; j++)
                {
                    double t = j * dt;
 
                    mesh.Positions.Add(GetPosition(t, y));
                }
            }
 Point3D GetPosition(double t, double y)
        {
            double x = Radius * Math.Cos(t);
            double z = Radius * Math.Sin(t);
            return new Point3D(x, y, z);
        }

TriangleIndices 是位置的集合,WPF 渲染系统连续拾取三角形索引中的 3 个位置,并将它们连接起来形成一个表面并进行渲染(遵循右手定则来确定表面的正面和背面),在上图中,0 4 1 1 4 5 1 5 2 2 5 6 2 6 3 3 6 7 3 7 0 0 7 4 是三角形索引,通过连接位置来形成表面。下面的代码连接位置以形成三角形。

for (int i = 0; i < lengthDivision; i++)
            {
                for (int j = 0; j < circumferenceDivision; j++)
                {
                   int x0 = j % circumferenceDivision + i * circumferenceDivision;//0
                   int x1 = (j + 1) % circumferenceDivision + i * circumferenceDivision;//1
                    int x2 = j + circumferenceDivision + i * circumferenceDivision;//4

                    int x3 = x1;//1
                    int x4 = x3 + circumferenceDivision;//5
                    int x5 = x2;//4
                   
                    mesh.TriangleIndices.Add(x0);
                    mesh.TriangleIndices.Add(x2);
                    mesh.TriangleIndices.Add(x1);
 
                    mesh.TriangleIndices.Add(x3);
                    mesh.TriangleIndices.Add(x5);
                    mesh.TriangleIndices.Add(x4);
               }
            }

如何制作立方体?

Cube 是由位置和三角形索引组成的 MeshGeometry3D,0,1,2,3,4,5,6,7 是立方体上的位置,每个点都相对于起始坐标,只需向 cube.cs 提供起始坐标和 widthheightdepth,它就会自己绘制。

public static DependencyProperty StartingPointCubeProperty = 
    DependencyProperty.Register("StartingPointCube", typeof(Point3D), typeof(Base3D), 
    new PropertyMetadata(OnPoint3dChanged));					

public Point3D StartingPointCube
        {
            get 
            { 
		       return (Point3D)GetValue(StartingPointCubeProperty); 
		    }
            set 
            {           
               SetValue(StartingPointCubeProperty, value);
            }
        }

磁铁以特殊的方式照亮,里面有一个 点光源 来提供照明。

PointLight light = new PointLight();
light.Position = new Point3D(point3d.X + widthHeightDepth, 
                 point3d.Y + widthHeightDepth, point3d.Z + widthHeightDepth);
light.Color = Colors.Red;
modelGroup.Children.Add(light);

立方体的放置如何?

在网格中,有 125 个空块可以放置立方体,想象有 5 层,每层有 25 个块(x 方向 5 个块 * z 方向 5 个块)。

public static int BlocksInXdirection = 5;
public static int BlocksInZdirection = 5;
public static int NoofFloor = 5; 

有 12 个立方体,每种颜色 4 个(红、蓝、绿),还有一个移动的磁铁立方体,这些立方体随机放置在 125 个块中的任何一个,下面的代码执行相同的操作。

int xcoor, ycoor, zcoor;
int floorNo = -1;
int positionOnFloor = randomCube.Next(0, cubesPerFloor);
 
Random randomSteps = new Random();
 
for (int i = 1; i <= TotalCubes; i++)
    {
          positionOnFloor = randomCube.Next(0, cubesPerFloor);
 
          Color color = ColorsCollection[i % 3];
 
          floorNo = (floorNo + 3) % Constants.NoofFloor;
 
          //This position is unoccupied
      if (position[floorNo][positionOnFloor] == null)
      {
        xcoor = (int)(positionOnFloor % (Constants.BlocksInXdirection));
        ycoor = floorNo;
        zcoor = (int)(positionOnFloor / (Constants.BlocksInZdirection));
 
 position[floorNo][positionOnFloor] = PlaceCube(xcoor, floorNo, zcoor, color);
      }        
    }

我们在数据结构 position 中维护每个放置的立方体,position 是一个字典,其键是楼层号,值是 dictionary<int,cube>,内部字典的键表示楼层上的位置。

下面的代码在 3D 空间中指定的坐标处创建 Cube

private Cube PlaceCube(int xCoor, int yCoor, int zCoor, Color color)
{
      double cubeLength =Constants.CubeLength;
      Cube cube3d = new Cube();
      cube3d.Transform = Translate;
      cube3d.color = color;
      cube3d.WidthHeightDepth = Constants.CubeLength;
      cube3d.opacity = 1;
      cube3d.StartingPointCube = new Point3D(cubeLength * xCoor, 
                                 cubeLength * yCoor,cubeLength * zCoor);
      cubesCollection.Add(cube3d);
}

默认情况下,磁铁的选定位置是第 2 层,第 22 个位置。

this.Magnet = PlaceCube(Constants.MagnetBlockXDirection,
     Constants.MagnetBlockYDirection, Constants.MagnetBlockZDirection, Colors.Yellow); 
           
this.MagnetFloorNo = Constants.MagnetBlockYDirection;
this.MagnetPositionOnFloor = Constants.BlocksInXdirection * 
     Constants.MagnetBlockZDirection + Constants.MagnetBlockXDirection;
         
this.position[this.MagnetFloorNo][this.MagnetPositionOnFloor] = this.Magnet;
this.Magnet.IsMovingCube = true;

磁铁的移动如何?

Magnet 可以推动其他立方体,方向是它移动的方向。在磁铁移动之前,我们会检查在其移动方向上是否有空位。

示例 1:假设磁铁从第 1 层当前位置 24 向左移动,它可以移动(如果同一层上的位置 23、22、21、20 中有任何一个为空)。

示例 2:假设磁铁从第 1 层当前位置 22 向后移动,它可以移动(如果同一层上的位置 2、7、12、17 中有任何一个为空)。

示例 3:假设磁铁从第 1 层当前位置 10 向上移动,它可以移动(如果第 2、3、4、5 层上的位置 10 中有任何一个为空),下面的代码片段执行相同的操作。

case Direction.Up:
for (counter = MagnetFloorNo + 1; counter < Constants.NoofFloor; counter++)
 {
    if (position[counter][MagnetPositionOnFloor] == null)
          {
            emptyPositionOrFloor = counter;
            canMove = true;
            break;
          }
 }   

如果磁铁可以移动,就会发生很多事情。

  1. Camera positionlookdirectionfieldofview 被动画化,下面的代码片段执行相同的操作。
    this.ViewModelCamera.BeginAnimation(PerspectiveCamera.PositionProperty, 
         animationKeyFramesCameraPosition, HandoffBehavior.Compose);
    this.ViewModelCamera.BeginAnimation(PerspectiveCamera.LookDirectionProperty, 
         animationKeyFramesCameraLookDirection, HandoffBehavior.Compose);
    this.ViewModelCamera.BeginAnimation(PerspectiveCamera.FieldOfViewProperty, 
         animationKeyFramesFieldofView, HandoffBehavior.Compose);  
  2. 磁铁移动时,datastructure position 被更新(旧的 position 被清空,新的 position 被填充),下面的代码片段执行相同的操作。
    case Direction.Left:
    if (cubeLocation.X >= 0)
     {
    for (counter = emptyPositionOrFloor + 1; counter < MagnetPositionOnFloor; counter++)
         {
               this.MoveBlocks(position[MagnetFloorNo][counter], 1, Direction.Left);
               position[MagnetFloorNo][counter - 1] = position[MagnetFloorNo][counter];
         }
     
    position[MagnetFloorNo][MagnetPositionOnFloor - 1] = 
                position[MagnetFloorNo][MagnetPositionOnFloor];
                position[MagnetFloorNo][MagnetPositionOnFloor] = null;
                MagnetPositionOnFloor--;
                goto default;
     }....  
  3. Magnet 的移动被动画化,下面的代码片段执行相同的操作。
    MovingCube.BeginAnimation(Cube.StartingPointCubeProperty, 
                              animationKeyFrames, HandoffBehavior.Compose); 
  4. 只有当所有 animation 都停止后,才能进行新的磁铁移动。下面的代码片段确保了这一点。
    if (this.cubeAnimationCompleted == true && 
        this.positionAnimationCompleted == true && 
        this.cameraLookdirectionAnimationCompleted == true && 
        this.fieldViewAnimationCompleted == true && 
        CountMovingBlocks == 0)

    以下事件确保 animation 已完成,只有这样才能进行新的移动。

    private void AnimationKeyFramesCameraPosition_Completed(object sender, EventArgs e)
     {
         cameraLookdirectionAnimationCompleted = true;
     } 
    private void AnimationKeyFramesCameraLookDirection_Completed(object sender, EventArgs e)
     {
          positionAnimationCompleted = true;
     } 
    private void AnimationFieldView_Completed(object sender, EventArgs e)
     {
          fieldViewAnimationCompleted = true;
     } 
    private void AnimationKeyFramesBox_Completed(object sender, EventArgs e)
     {
          cubeAnimationCompleted = true;
          CheckCompletness();
     } 
  5. MagnetPositiononFloorMagnetFloorNomagnet 移动时更新。
    • magnet 向后移动时。
      MagnetPositionOnFloor = MagnetPositionOnFloor - Constants.BlocksInXdirection; 
    • magnet 向上移动时。
      MagnetFloorNo++;   
    • magnet 向下移动时。
      MagnetFloorNo--; 
    • magnet 向左移动时。
      MagnetPositionOnFloor--; 
    • magnet 向右移动时。
      MagnetPositionOnFloor++;
    • magnet 向前移动时。
      MagnetPositionOnFloor = MagnetPositionOnFloor + Constants.BlocksInXdirection; 
  6. 旧事件被取消订阅,新事件被订阅。
    if (animationFieldView != null)
        {
          animationFieldView.Completed -= new EventHandler(AnimationFieldView_Completed);
        }
                 
    animationFieldView = new DoubleAnimationUsingKeyFrames();
    animationFieldView.Completed += new EventHandler(AnimationFieldView_Completed);...	
  7. Magnet 推动其他立方体,这里 cube3d 是一个立方体实例,它被 magnet 以指定的步数在(Left, Right, Up, Down, Front, Back)方向上推动。
    private void MoveBlocks(Cube cube3d, int step, Direction direction).... 
  8. magnet 移动时,会在磁铁动画完成后检查图案的完整性,如上面的事件所示。
    private void CheckCompletness(); 
  9. StepCount 被更新,表示磁铁移动了多少步,并绑定到视图。
     public int StepCount
            {
                get
                {
                    return stepCount;
                }
                set
                {
                    this.stepCount = value;
                    NotifyPropertyChanged("StepCount");
                }
            }

如何将键盘事件与 keyargs 传递给 ViewModel?

在主窗口的 keydown 事件中,我们希望调用 viewmodel 中的一个函数并传递 key args,为此,使用 System.Windows.Interactivity DLL 将 Keyboard 事件绑定到 Delegate 命令。

<i:Interaction.Triggers> 
<i:EventTrigger EventName="KeyDown">
<local:InvokeDelegateCommandAction 
 Command="{Binding KeyDownCommand}"
 CommandName="KeyDownCommand"
 CommandParameter="{Binding RelativeSource={RelativeSource Self}, Path=InvokeParameter}" />
</i:EventTrigger>									   </i:Interaction.Triggers>

InvokeDelegateCommandAction 是用于传递 keyargs 的自定义触发器操作,参考了这篇 文章

如何确保立方体在视线范围内可见?

magnet 的每次移动时,我们都会计算其他立方体与 camera 位置的距离,并保持旧距离。

private List<Cube> CalculateDistance(PerspectiveCamera camera)
     {
          Cube cube;
            Vector3D vector;
            List<Cube> cubeCollection = new List<Cube>();
            for (int floor = 0; floor < Constants.NoofFloor; floor++)
            {
                for (int i = 0; i < Constants.NoofBlocksInXdirection * 
                                Constants.NoofBlocksInZdirection; i++)
                {
                    if (!((position[floor][i] == null) || 
                       (floor == MagnetFloorNo && i == MagnetPositionOnFloor)))
                    {
                        cube = position[floor][i] as Cube;
                        vector = Point3D.Subtract(camera.Position, cube.Point3DCircuit);
                        cube.OldDistanceFromViewer = cube.NewDistanceFromViewer;
                        cube.NewDistanceFromViewer = vector.Length;
                        cubeCollection.Add(cube);
                    }
                }
            }
 
            return cubeCollection.OrderByDescending(x => x.NewDistanceFromViewer).ToList();
        }

通过区分旧距离和新距离来改变透明度,如果立方体比旧位置更远,则增加透明度,否则减少透明度。

透明度是动画化的,而不是一次性改变的。

 if ((cubeCollection[i].NewDistanceFromViewer - 
          cubeCollection[i].OldDistanceFromViewer) > 0)
                {
                    oldOpacity = .8;
                    delta = (.1) / factor;
                }
                else
                {
                    oldOpacity = 1;
                    delta = -(.2) / factor;
                }
 
                for (int count = 1; count < factor; count++)
                {
                    newOpacity = oldOpacity + delta * count;
                    if (newOpacity > 0 && newOpacity <= 1)
                    {
                        LinearDoubleKeyFrame linearkeyFrame = 
                                       new LinearDoubleKeyFrame(newOpacity);
 
                        opacitykeyFrame.KeyFrames.Add(linearkeyFrame);
                    }
                }
 
cubeCollection[i].BeginAnimation(Cube.opacityProperty, opacitykeyFrame, 
                                 HandoffBehavior.SnapshotAndReplace); 

为什么需要动画化相机的 position、look-direction、fieldOfView 属性?

为了完美地观察磁铁在 3D 网格中的移动,需要对 camera 进行动画化,当 magnet 向左/右/上/下/前/后移动时,camera 会相应地向左/右/上/下/前/后移动,以保持对 magnet 的聚焦。

在磁铁向前移动时有特殊处理,fieldofview 会减小,在立方体向后移动时,fieldofview 会通过固定坐标增大。

例如,假设 cube 从当前位置移动到左侧网格块,camera.position.x 坐标减少了 delta,但 camera.lookdirection.x 坐标增加了 delta。

注意:在动画化 camera.positioncamera.lookdirectioncamera.fieldofview 的同时,我们确保 camera.lookdirection.magnitude 保持不变。

如何动画化相机的 position、look-direction、fieldOfView 属性?

分别使用 Point3DAnimationUsingKeyFramesVector3DAnimationUsingKeyFramesDoubleAnimationUsingKeyFrames 来动画化 position、look-direction、fieldOfView

无论磁铁如何移动,例如,立方体沿 x 坐标方向向上移动,其距离等于立方体的长度。

this.ViewModelCamera.BeginAnimation(PerspectiveCamera.PositionProperty, 
     animationKeyFramesCameraPosition, HandoffBehavior.Compose);
this.ViewModelCamera.BeginAnimation(PerspectiveCamera.LookDirectionProperty, 
     animationKeyFramesCameraLookDirection, HandoffBehavior.Compose); 
this.ViewModelCamera.BeginAnimation(PerspectiveCamera.FieldOfViewProperty, 
     animationKeyFramesFieldofView, HandoffBehavior.Compose); 

ViewModel 中的以下属性绑定到透视 Camera 的依赖属性。

 public Point3D CameraPosition
        {
            get
            {
                return this.cameraPosition;
            }
            set
            {
                this.cameraPosition = value;
                NotifyPropertyChanged("CameraPosition");
            }
        } 
 public double FieldofView
        {
            get
            {
                return this.fieldofView;
            }
            set
            {
                this.fieldofView = value;
                NotifyPropertyChanged("FieldofView");
            }
        }	
public Vector3D CameraLookDirection
        {
            get
            {
                return this.cameraLookDirection;
            }
            set
            {
                this.cameraLookDirection = value;
                NotifyPropertyChanged("CameraLookDirection");
            }
        }

Camera 属性封装了上述属性,并注册了一个 Changed 事件。

private PerspectiveCamera ViewModelCamera
        {
            get
            {
                if (camera == null)
                {
                    camera = new PerspectiveCamera
                    (CameraPosition, CameraLookDirection, 
                    new Vector3D(0, 0, 0), FieldofView);
                    camera.Changed += new EventHandler(Camera_changed);
                }
 
                return camera;
            }
        }

ViewModelCamera 的任何属性发生变化时,就会触发 Changed 事件,并通知 MainWindow.xml 进行更新。

MVVM 模式?

代码基于 MVVM 模式,MagnetViewModel 的实例被设置为 datacontext

  MagnetViewModel viewModel = new MagnetViewModel(); 
  this.DataContext = viewModel; 

整个代码最初是在代码隐藏中编写的,后来迁移到 MVVM 模式,使代码完全可测试,但代码隐藏文件中仍有一小部分代码用于处理 ViewPort3d 中立方体的添加和移除。将所有立方体添加到 ViewPort3D 是一次性活动,当图案形成并且需要从 Viewport3D 中移除立方体时,会发生立方体的移除。

void ViewModel_CollectionChangedChanged
(object sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
            List<Cube> removeCubes = sender as  List<Cube>;
 
            for (int k = 0; k < removeCubes.Count; k++)
            {
                this.ViewPort3dPentagon.Children.Remove(removeCubes[k]);
            }
        } 

CollectionChanged 事件从 view model 触发(已注册并监听),在代码隐藏文件中用于移除立方体。

关注点

  • 这个游戏可能有很多变体,这里只突出了基本版本。
  • 最重要的事情是注意应用程序在可用性和系统资源方面的性能。

最有趣的部分是,它从一个东西开始,最终变成了另一个东西。

如果您喜欢这篇文章,请投票。祝您愉快!

历史

  • 2013 年 12 月 17 日:初始版本
© . All rights reserved.