拼图演示
资源:
链接:https://pan.baidu.com/s/1BGeSmRCO_WZRUyl3MxefGw
提取码:0n4a
排列拼图碎片,拼出最后的图案。可以点住碎片的任意位置拖动;点击"重来"按钮,可以回到最初状态重新开始。
有很多电脑游戏的原型来自于现实世界中的玩具,拼图游戏就是其中的一个代表。
本文我们介绍的拼图游戏虽然是一款玩法比较简单的游戏,不过这并不意味着开发也非常简单。
相对于其他游戏通过操作键盘或移动鼠标来控制角色的运动方向,拼图游戏通过鼠标的拖拽直接移动拼图的碎片。
游戏的核心在于流畅的拖拽操作。
除了拖拽操作外,我们也可以借此机会思考一下诸如"当碎片移动到正确的位置附近时会被吸附到正确的位置"等触屏游戏的小细节。
Unity可以很容易判断出"某个对象受到了点击",不过如果要实现自然流畅的操作,我们仍需下功夫。这里,为了让鼠标的拖拽操作更急接近"用手指摁住移动"的效果,我们需要考虑一下如何才能点住碎片的任意位置拖动。
鼠标的光标位于屏幕上时,其位置坐标位于二维坐标系内。而拼图碎片位于3D空间内,所以其位置坐标自然有三个维度。为了比较鼠标光标和拼图碎片的位置,必须将它们放入相同的坐标系。因此,我们使用逆透视变换的方法,将鼠标光标的坐标变换至三维坐标系
通过逆透视变换将鼠标光标和拼图碎片的坐标统一到相同的坐标系后,我们就该尝试通过拖拽使拼图移动了,只需要在点击按键的瞬间,将鼠标光标的坐标复制到拼图碎片的坐标即可。这种方法确实非常简单,不过它有个缺点:鼠标光标总是显示在拼图碎片的中心。
在本游戏中,拼图碎片被点击的位置并不影响游戏的玩法。不过,对于某些游戏而言,点击位置的不同可能会改变角色的朝向,或者是游戏角色以光标为中心摆动,这些情况下在何处点击就变得很重要了。
而且,即使不影响游戏的核心玩法,点击的瞬间拼图碎片会突然移动一下这种体验也很糟糕。尽管有些时候这种机制可能会更好,但是为了应对不同的要求,我们还是需要掌握如何能点住碎片的任意处拖动。
在本游戏中,碎片的点击判断都是通过Unity的网格碰撞器实现的。网格碰撞器采用网格进行碰撞检测,点击拼图碎片的任何部位都将发生碰撞。对于玩家来说点击碎片的哪个位置都可以,这反映到程序中就是"不用关心碎片的何处受到了点击"。
点击的瞬间,鼠标光标不一定位于碎片的中心。两者的坐标存在一定的差距,我们将这种坐标的差距称为偏移。
之前我们把光标的坐标原原本本地复制到碎片坐标时,因为两个坐标值相同所以差距为0,这种坐标差的急剧变化正是导致拼图碎片突然移动的原因。
知道了坐标偏移值的变化是问题所在后,我们来考虑如何固定这个偏移值。首先,要在鼠标点击拼图碎片的瞬间,也就是开始拖动的时候,计算出鼠标光标和碎片中心的坐标差,得到的值就是偏移值。
偏移=碎片的位置-鼠标光标的位置
拖动的过程中则与之相反,用鼠标光标的位置加上偏移值就可以得到碎片的位置
碎片的位置=鼠标光标位置+偏移值
这样一来,鼠标光标距离碎片中心总是保持一定的距离,这样就保证了鼠标点击瞬间的位置就是碎片被拖拽的位置。
下面来看看实际的代码
private void begin_dragging()
{
do {
// 将光标坐标变换为3D空间内的世界坐标
Vector3 world_position;
if(!this.unproject_mouse_position(out world_position, Input.mousePosition)) {
break;
}
if(PieceControl.IS_ENABLE_GRAB_OFFSET) {
// 求出偏移值(点击位置距离碎片的中心有多远)
this.grab_offset = this.transform.position - world_position;
}
} while(false);
}
private void do_dragging()
{
do {
// 将光标坐标变换为3D空间内的世界坐标
Vector3 world_position;
if(!this.unproject_mouse_position(out world_position, Input.mousePosition)) {
break;
}
// 加上光标坐标(3D)的偏移值,计算出碎片的中心坐标
this.transform.position = world_position + this.grab_offset;
} while(false);
}
商店里售卖纸质拼图游戏时一般会将拼图碎片打乱顺序后放入包装盒中。虽然也有些是已经拼好的状态,不过玩家在开始游戏之前还是要将各碎片的顺序打乱。
有很多事情都是"人类做起来很简单,计算机处理起来却很难",比如将拼图碎片全部打乱这件事就是一个例子。
Unity提供了取得随机数的方法,不过单纯使用该方法似乎并不能达到打乱碎片顺序的目的。
这里我们不妨来分析一下如何随机打乱各拼图碎片的顺序。
最简单的随机打乱拼图碎片的方法是,直接将随机数代入各个碎片的坐标。只要控制好随机数的范围,就能让各个拼图碎片随机分布在画面上。
但这种方式的弊端也很明显,就是有很多拼图碎片可能叠在一起。虽然这样也未尝不可,不过可以的话最好还是将各碎片均匀分散开。如果很多碎片重叠在一起,就可能导致下面的碎片被覆盖而无法看见。
首先我们整理一下拼图碎片随机散开的要求,即需求分析
- 碎片之间彼此互不重叠
- 碎片散开分布到整个画面上
- 随机分散各个碎片
需求基本上就是这样。如果拼图碎片的数量有所增加,可能还需要追加一项"能够控制游戏的难易度"。
接下来我们对实现方法进行说明。首先简单熟悉一下整体流程
- 将拼图碎片分配到网格中
- 打乱拼图碎片的排列顺序
- 在网格内通过随机坐标调整碎片的位置
- 将整个拼图随机旋转一定角度
我们可以选择任意图片,将其分割成几块。这里我们选择一个"猫头鹰"图片,将其分割成8块。
该网格的行数和列数相同,并且网格总数达于拼图碎片数量。"猫头鹰"拼图碎片数量为8,我们就搞一个3✖️3的网格,空出来的格子不用理会。根据碎片数量的不同,有时候剩余的格子会比较多,这种情况下可以调整网格的行数和列数。
所有网格块都为正方形,且都应当能确保能够容纳下拼图碎片。另外,因为后续步骤在网格内移动拼图碎片,所以还需要在确保整体网格不溢出画面的前提下适当放大网格的尺寸。
之所以想这样把拼图碎片纺织到网格中,是为了避免出现碎片之间彼此重叠的状况。
在第一个步骤中,我们将碎片从左上角开始依次放入了网格中。而第二个步骤就是打乱各个碎片的排列顺序。利用随机数选出两个网格,案后交换其中的碎片空白的网格也可以参与交换。
做到这里,前面我们做出的需求分析中,"碎片之间彼此互不重叠","碎片分散于整个画面"和"随机分散各个碎片"就已经基本得到了实现。不过从程序实际情况来看,很容易发现拼图碎片被规则地排列在了网格上。我们得想办法让这种随机分散的效果更真实。
最初的步骤中增加网格尺寸的用意就在于为这里的碎片移动做准备。如果网格的尺寸太小,将无法移动碎片,反之如果太大,则会令碎片之间过于松散。我们需要结合拼图碎片的大小和画面整体的尺寸,调整网格尺寸为最佳值,
虽然旋转了整体的网格,但是需要保持拼图碎片自身的角度不变。
下面,我们结合实际代码来看看这一流程
private void shuffle_pieces()
{
#if true
// 将碎片按照网格顺序排列
int[] piece_index = new int[this.shuffle_grid_num*this.shuffle_grid_num];
for(int i = 0;i < piece_index.Length;i++) {
if(i < this.all_pieces.Length) {
piece_index[i] = i;
} else {
piece_index[i] = -1;
}
}
// 随机选取两个碎片,交换位置
for(int i = 0;i < piece_index.Length - 1;i++) {
int j = Random.Range(i + 1, piece_index.Length);
int temp = piece_index[j];
piece_index[j] = piece_index[i];
piece_index[i] = temp;
}
// 通过位置的索引变换为实际的坐标来进行配置
Vector3 pitch;
pitch = this.shuffle_zone.size/(float)this.shuffle_grid_num;
for(int i = 0;i < piece_index.Length;i++) {
if(piece_index[i] < 0) {
continue;
}
PieceControl piece = this.all_pieces[piece_index[i]];
Vector3 position = piece.finished_position;
int ix = i%this.shuffle_grid_num;
int iz = i/this.shuffle_grid_num;
position.x = ix*pitch.x;
position.z = iz*pitch.z;
position.x += this.shuffle_zone.center.x - pitch.x*(this.shuffle_grid_num/2.0f - 0.5f);
position.z += this.shuffle_zone.center.z - pitch.z*(this.shuffle_grid_num/2.0f - 0.5f);
position.y = piece.finished_position.y;
piece.start_position = position;
}
// 逐步(网格的格子内)随机移动位置
Vector3 offset_cycle = pitch/2.0f;
Vector3 offset_add = pitch/5.0f;
Vector3 offset = Vector3.zero;
for(int i = 0;i < piece_index.Length;i++) {
if(piece_index[i] < 0) {
continue;
}
PieceControl piece = this.all_pieces[piece_index[i]];
Vector3 position = piece.start_position;
position.x += offset.x;
position.z += offset.z;
piece.start_position = position;
//
offset.x += offset_add.x;
if(offset.x > offset_cycle.x/2.0f) {
offset.x -= offset_cycle.x;
}
offset.z += offset_add.z;
if(offset.z > offset_cycle.z/2.0f) {
offset.z -= offset_cycle.z;
}
}
// 使全体旋转
foreach(PieceControl piece in this.all_pieces) {
Vector3 position = piece.start_position;
position -= this.shuffle_zone.center;
position = Quaternion.AngleAxis(this.pazzle_rotation, Vector3.up)*position;
position += this.shuffle_zone.center;
piece.start_position = position;
}
this.pazzle_rotation += 90;
#else
// 简单地使用随机数来决定坐标时的情况
foreach(PieceControl piece in this.all_pieces) {
Vector3 position;
Bounds piece_bounds = piece.GetBounds(Vector3.zero);
position.x = Random.Range(this.shuffle_zone.min.x - piece_bounds.min.x, this.shuffle_zone.max.x - piece_bounds.max.x);
position.z = Random.Range(this.shuffle_zone.min.z - piece_bounds.min.z, this.shuffle_zone.max.z - piece_bounds.max.z);
position.y = piece.finished_position.y;
piece.start_position = position;
}
#endif
}