Magnet:3D 益智游戏






4.89/5 (29投票s)
你能解开这个吗?
引言
这项作品融合了数学和物理的基本原理。这款应用程序是三维空间中的一个脑筋急转弯,其理念是使用一个黄色的立方体(也称为磁铁)在 3D 网格中向左/右、前/后、上/下方向推动立方体,并形成图 1 左侧所示的图案。一旦图案形成,图案就会消失,游戏结束。
背景
这是一个视频演示
Using the Code
上图是应用程序的快照,使用 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;
}
}
如果磁铁可以移动,就会发生很多事情。
Camera position
、lookdirection
、fieldofview
被动画化,下面的代码片段执行相同的操作。this.ViewModelCamera.BeginAnimation(PerspectiveCamera.PositionProperty, animationKeyFramesCameraPosition, HandoffBehavior.Compose); this.ViewModelCamera.BeginAnimation(PerspectiveCamera.LookDirectionProperty, animationKeyFramesCameraLookDirection, HandoffBehavior.Compose); this.ViewModelCamera.BeginAnimation(PerspectiveCamera.FieldOfViewProperty, animationKeyFramesFieldofView, HandoffBehavior.Compose);
- 磁铁移动时,
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; }....
Magnet
的移动被动画化,下面的代码片段执行相同的操作。MovingCube.BeginAnimation(Cube.StartingPointCubeProperty, animationKeyFrames, HandoffBehavior.Compose);
- 只有当所有
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(); }
MagnetPositiononFloor
、MagnetFloorNo
在magnet
移动时更新。- 当
magnet
向后移动时。MagnetPositionOnFloor = MagnetPositionOnFloor - Constants.BlocksInXdirection;
- 当
magnet
向上移动时。MagnetFloorNo++;
- 当
magnet
向下移动时。MagnetFloorNo--;
- 当
magnet
向左移动时。MagnetPositionOnFloor--;
- 当
magnet
向右移动时。MagnetPositionOnFloor++;
- 当
magnet
向前移动时。MagnetPositionOnFloor = MagnetPositionOnFloor + Constants.BlocksInXdirection;
- 当
- 旧事件被取消订阅,新事件被订阅。
if (animationFieldView != null) { animationFieldView.Completed -= new EventHandler(AnimationFieldView_Completed); } animationFieldView = new DoubleAnimationUsingKeyFrames(); animationFieldView.Completed += new EventHandler(AnimationFieldView_Completed);...
Magnet
推动其他立方体,这里cube3d
是一个立方体实例,它被magnet
以指定的步数在(Left
,Right
,Up
,Down
,Front
,Back
)方向上推动。private void MoveBlocks(Cube cube3d, int step, Direction direction)....
- 当
magnet
移动时,会在磁铁动画完成后检查图案的完整性,如上面的事件所示。private void CheckCompletness();
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.position
、camera.lookdirection
和 camera.fieldofview
的同时,我们确保 camera.lookdirection.magnitude
保持不变。
如何动画化相机的 position、look-direction、fieldOfView 属性?
分别使用 Point3DAnimationUsingKeyFrames
、Vector3DAnimationUsingKeyFrames
、DoubleAnimationUsingKeyFrames
来动画化 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 日:初始版本