空间与运动
这是
3D
游戏编程的第三次作业,花了很长时间,看博客内容你懂的(菜鸡本菜)。
说明文档
闪光点:
1
为太阳系中的星体增加了TrailRender
的轨迹效果!2
太阳系使用天空盒背景以及太阳使用粒子效果、效果震感!3
以下三点都在牧师与魔鬼实验中的困难解决小节中:3.1
理清父子物体中position
的关系,配以图示。3.2
正确地最小变化地修改字体大小(之所以说正确地,是因为很多博客都没正确处理字体大小该如何保持除了字体之外的属性完全不变化)。3.3
怎样最快调整相机(/风区/光线)的位置使得我们看得舒适、操作得畅快?
作业内容
1.
简答并用程序验证【建议做】
[Q]
游戏对象运动的本质是什么?- 游戏运动本质就是使用矩阵变换(平移、旋转、缩放)改变游戏对象的空间属性。具体而言,空间属性由
Transforn
来显现,而随着游戏循环的进行,每一帧都可以做出相应的属性调整,也就是属性对时间的函数。下面的代码就是利用每一帧的更新来更新游戏对象的运动。public class MoveLeft : MonoBehaviour { void Update() { // 每一帧进行调用 this.transform.position += Vector3.left * Time.deltaTime; } }
- 游戏运动本质就是使用矩阵变换(平移、旋转、缩放)改变游戏对象的空间属性。具体而言,空间属性由
[Q]
请用三种方法以上方法,实现物体的抛物线运动。(如,修改Transform
属性,使用向量Vector3
的方法…)-
运行提示:下面的代码的游戏对象是由脚本
parabola.cs
动态生成的,只需要将该脚本挂载到Main Camera
或者空对象中即可点击运行,看到效果。 -
方法1:修改
Transform
属性,使用using System.Collections; using System.Collections.Generic; using UnityEngine; public class parabola : MonoBehaviour { // Start is called before the first frame update public GameObject Sphere; // 重力加速度为10(单位)/s^2 private float gravity = 10.0f; // 水平速度5(单位)/s private float speed_x = 5.0f; // 竖直方向初速度为0 private float speed_y = 0f; void Start() { // 初始化球体 Sphere = GameObject.CreatePrimitive(PrimitiveType.Sphere); Sphere.transform.position = new Vector3(0, 15, 0); } // Update is called once per frame void Update() { if(Sphere.transform.position.y <= 0) { return; } // vy = v0 + gt speed_y += gravity * Time.deltaTime; // 更新水平匀速运动 Sphere.transform.position += Vector3.left * speed_x * Time.deltaTime; // 更新竖直方向的匀加速运动 Sphere.transform.position += Vector3.down * speed_y * Time.deltaTime; } }
-
方法2:使用向量
Vector3
做出抛物线运动的简便表达public class parabola : MonoBehaviour { // 为方便查看区别,除update方法之外与方法1保持一致 // Update is called once per frame void Update() { if(Sphere.transform.position.y <= 0) { return; } // vy = v0 + gt speed_y += gravity * Time.deltaTime; // 创建抛物线运动的向量: // x为负是为了与上一次代码的Vector3.left对应 Vector3 parabola = new Vector3(-speed_x * Time.deltaTime, -speed_y * Time.deltaTime, 0); // 更新位置 Sphere.transform.position += parabola; } }
-
方法3:使用
transform.Translate
方法更新位置public class parabola : MonoBehaviour { // 为方便查看区别,除update方法之外与方法1保持一致 // Update is called once per frame void Update() { if(Sphere.transform.position.y <= 0) { return; } // vy = v0 + gt speed_y += gravity * Time.deltaTime; // 创建抛物线运动的向量: // x为负是为了与上一次代码的Vector3.left对应 Vector3 parabola = new Vector3(-speed_x * Time.deltaTime, -speed_y * Time.deltaTime, 0); // 使用transform.Translate更新位置 Sphere.transform.Translate(parabola); } }
-
效果展示 (上面代码的运行效果都是一样的) :
默认以
Y=0
的平面为地面,着地时停止运动,可查看前面的代码中做出的落地判断。
-
-
[Q]
写一个程序,实现一个完整的太阳系, 其他星球围绕太阳的转速必须不一样,且不在一个法平面上。-
1.0
版本:-
预制制作:
- 首先在网站太阳系贴图下载太阳系各星体的贴图图片到
Resources/Pictures
下;
- 得到贴图后,我们为每个星体制作预制。制作预制在之前的作业已经做过,很简单,我们首先创建一个
3D
球体,并将刚才得到的贴图直接拖拽到物体上,即得到对应星体的预制;我们将这些预制都拖到Resources/Prefabs
目录下,用于后面代码动态载入。
- 首先在网站太阳系贴图下载太阳系各星体的贴图图片到
-
位置安排:
-
创建
SolarSystem.cs
脚本并挂载在Main Camera
上,我们将上面的预制动态加载到场景中,这同时需要加载预制、放置合适位置和设置合适大小,所以不妨先将其抽取出一个LoadPrefab
函数:private Transform LoadPrefab(string path, int locate, int radius) { // 加载预制 var res = Resources.Load<Transform>(path); // 实例化 Transform sphere = Instantiate(res, new Vector3(locate, 0, 0), Quaternion.identity); // 调整星体大小 radius *= 2; // 由于操作的是球体,其Scale设置为直径 sphere.localScale = new Vector3(radius, radius, radius); // 返回 return sphere; }
-
利用刚才的函数,我们为每个星体设定合适的大小,并且在设置位置时遵循:除太阳外,每个星体与上一个星体之间的间隔是该星体的半径。之所以这样设置是因为更新中心点坐标的代码可以复用:
/* // 八大行星 public Transform mercury, venus, earth, mars, jupiter, saturn, uranus, neptune; // 太阳和月亮 public Transform sun, moon; */ void Start() { // 加载预制 // 中心点,半径 int locate = 0, radius = 0; // 太阳 locate = 0; radius = 20; sun = LoadPrefab("Prefabs/Sun", locate, radius); // 水星 locate += radius; radius = 2; // 往后的代码只需要修改半径 locate += 2 * radius; mercury = LoadPrefab("Prefabs/Mercury", locate, radius); // 金星 locate += radius; radius = 4; locate += 2 * radius; venus = LoadPrefab("Prefabs/Venus", locate, radius); // 篇幅原因,其他星体的设置不放上来了 // 可以到下面给出的gitee仓库里面查看 }
-
运行上述代码,我们将得到我们初始位置的排布,如下图所示:
-
-
设置转动:
- 利用
RotateAround
实现公转,Rotate
实现自转;每个星体旋转的法平面不同(PS
:月球与地球共面使得轨迹观察起来比较正常)。void Update() { // RotateAround公转 // Rotate自转 mercury.RotateAround(sun.position, new Vector3(0, 2, -1), 50 * Time.deltaTime); mercury.Rotate(Vector3.up * 50 * Time.deltaTime); venus.RotateAround(sun.position, new Vector3(0, 5, -3), 40 * Time.deltaTime); venus.Rotate(Vector3.up * 40 * Time.deltaTime); earth.RotateAround(sun.position, new Vector3(0, 2, 1), 30 * Time.deltaTime); earth.Rotate(Vector3.up * 30 * Time.deltaTime); mars.RotateAround(sun.position, new Vector3(0, 5, 1), 22 * Time.deltaTime); mars.Rotate(Vector3.up * 20 * Time.deltaTime); jupiter.RotateAround(sun.position, new Vector3(0, 4, 9), 20 * Time.deltaTime); jupiter.Rotate(Vector3.up * 16 * Time.deltaTime); saturn.RotateAround(sun.position, new Vector3(0, 2, 5), 18 * Time.deltaTime); saturn.Rotate(Vector3.up * 14 * Time.deltaTime); uranus.RotateAround(sun.position, new Vector3(0, 3, 3), 16 * Time.deltaTime); uranus.Rotate(Vector3.up * 12 * Time.deltaTime); neptune.RotateAround(sun.position, new Vector3(0, 3, 7), 14 * Time.deltaTime); neptune.Rotate(Vector3.up * 10 * Time.deltaTime); // 月球的速度必须快,否则轨迹难以控制 moon.RotateAround(earth.position, new Vector3(0, 2, 1), 500 * Time.deltaTime); }
- 利用
-
添加轨迹:
- 如果只是运行上面的代码的话,星体的运动看起来比较乱,于是我们可以为每个星体都添加一个
TrailRender
来渲染各自的运动轨迹。所以不妨抽取出函数AddTrail
:private void AddTrail(Transform trans, float time) { // time表示轨迹保留时间 // 动态添加 trans.gameObject.AddComponent<TrailRenderer>(); // 设置时间,使得我们看到完整的轨迹圆 trans.GetComponent<TrailRenderer>().time = time; }
- 随后我们就可以在
Start
为除了太阳之外的星体调用该函数:void Start() { // ... 上面的代码保持不变 // 增加轨迹 const int time = 30; AddTrail(mercury, time); AddTrail(venus, time); AddTrail(earth, time); AddTrail(moon, 0.5f); // 避免月球轨迹影响其他观看 AddTrail(mars, time); AddTrail(jupiter, time); AddTrail(saturn, time); AddTrail(uranus, time); AddTrail(neptune, time); }
- 如果只是运行上面的代码的话,星体的运动看起来比较乱,于是我们可以为每个星体都添加一个
-
效果展示:
-
-
2.0
版本:太阳自发光 (使用粒子系统以及点光源)-
参考自官方教程:Unity 5.x/2017 标准教程
-
制作并使用天空盒
StarSky
:- 首先创建材质,并命名为
StarSky
; - 进入该材质的
Inspector
,并将shader
更改为Skybox/6 sided
:
- 随后将星星图拖拽到六个
HDR
框框中,并更改exposure
为0.5
则制作完成:
- 依次选择
Window
、Rendering
、Lighting
,跳出Lighting
弹窗,切换至environment
选项,并将其下的Skybox Material
设置为刚刚制作好的天空盒StarSky
:
- 此时就能看到星空背景了:
- 首先创建材质,并命名为
-
运用粒子系统制作太阳日冕效果:
- 首先下载上述教程中提供的
smoke.tif
:
- 依次选择
GameObject
、Effects
、Particle System
创建一个粒子系统,将其重命名为SunSurface
并将trandform
修改为如下:
- 在
SunSurface
的shape
模块中修改如下参数:
- 在
Start Size
右侧单击三角形并下拉菜单,选择Random Between Two Constants
,并填入10
和25
:
Start Speed
设置为0
:
- 创建新材质命名为
SunSurface
,将其shader
改为Particles/Additive (Soft)
并将刚才的smoke.tif
拖拽到它的Particle Texture
中:
- 将
SunSurface
材质拖入到粒子系统SunSurface
的检视视图中的Render
模块里的Material
中并修改Max Particle Size
为1
:
- 启用
Color over Lifetime
模块,单击颜色块,在弹窗中设置颜色大致如下:
- 在
Start Lifetime
右侧单击三角形并下拉菜单,选择Random Between Two Constants
,并填入25
和5
:
- 在
Emission
模块里将Rate over Time
设置为500
:
- 在
Start Rotation
右侧单击三角形并下拉菜单,选择Random Between Two Constants
,并填入-180
和180
:
- 启用
Rotation over Lifetime
模块,并
在Augular Velocity
右侧单击三角形并下拉菜单,选择Random Between Two Constants
,并填入-15
和15
:
- 到这里就能看到粒子系统的效果:
- 首先下载上述教程中提供的
-
增加新太阳预制:
- 为了更好的展示粒子效果,太阳预制就不再使用太阳系1.0中的贴图了,我们新创建一个材质命名为
SunColor
,并调整Albedo
为#000000
,Emission
为#FFB200
:
- 创建一个
3D
球体Sun
,并将上面制作好的SunColor
材质拖给它,并修改Transform
中的Scale
为(20, 20, 20)
,粒子系统的半径10
故此处直径为20s
:
- 此时我们就能够和之前的粒子系统一起看到日冕效果:
- 我们将球体
Sun
拖动到粒子系统SunSurface
下,成为父子,随后我们为粒子系统SunSurface
增加Light
组件,调整Type
为Point
,Range
为1000
,并修改Color
:
- 将
SunSurface
拖到Resources/Prefabs
下,形成预制。
- 为了更好的展示粒子效果,太阳预制就不再使用太阳系1.0中的贴图了,我们新创建一个材质命名为
-
修改部分代码:
- 需要修改代码的地方只有
Start
函数:void Start() { // 加载预制 // 中心点,半径 int locate = 0, radius = 0; // 修改开始 // 加载带有粒子效果太阳 sun = LoadPrefab("Prefabs/SunSurface", locate, 0.5f); locate = 0; // 为的是位置平衡 radius = 20; // 修改结束 // 水星 locate += radius; radius = 2; // 往后的代码只需要修改半径 locate += 2 * radius; mercury = LoadPrefab("Prefabs/Mercury", locate, radius); // ... }
- 需要修改代码的地方只有
-
效果展示:
- 上面的修改已经可以运行代码了,可以关掉默认光源,因为太阳已经可以发光
PS
:动图经过压缩所以后面天空盒不清晰,建议使用我的项目代码运行观看。
-
-
2.
编程实践
-
阅读以下游戏脚本
Priests and Devils
Priests and Devils is a puzzle game in which you will help the Priests and Devils to cross the river within the time limit. There are 3 priests and 3 devils at one side of the river. They all want to get to the other side of this river, but there is only one boat and this boat can only carry two persons each time. And there must be one person steering the boat from one side to the other side. In the flash game, you can click on them to move them and click the go button to move the boat to the other direction. If the priests are out numbered by the devils on either side of the river, they get killed and the game is over. You can try it in many > ways. Keep all priests alive! Good luck! -
程序需要满足的要求:
-
play the game
( http://www.flash-game.net/game/2535/priests-and-devils.html ) -
列出游戏中提及的事物(
Objects
)3
个牧师、3
个魔鬼、2
个陆地、1
条河流、1
条船
-
用表格列出玩家动作表(规则表),注意,动作越少越好
玩家动作 条件 效果 点击船上的牧师或魔鬼 船在陆地侧而不是在河流中 所点击的角色移动到陆地上 点击陆地上的牧师或魔鬼 船在陆地侧并且船未满员 所点击的角色移动到船上 点击船 船在陆地侧并且船上至少有一个角色 船移动至对面陆地 -
请将游戏中对象做成预制:
- 贴图:
- 材质:
- 做成预制:
-
在场景控制器
LoadResources
方法中加载并初始化 长方形、正方形、球 及其色彩代表游戏中的对象。- 场景控制器即后面将会提及到的
FirstController
(实现了ISceneController
接口),这里想要说的应该是如何通过代码加载预制:// 以加载船为例,首先加载预制资源 var res = Resources.Load<GameObject>("Prefabs/Boat"); // 再实例化该资源成为游戏对象 GameObject boat = Object.Instantiate(res);
- 当然
MVC
结构中,不会直接在场景控制器中调用上述的显示代码,而是将任务交接给对应Model
的控制器
- 场景控制器即后面将会提及到的
-
使用
C#
集合类型 有效组织对象 -
整个游戏仅 主摄像机 和 一个
Empty
对象, 其他对象必须代码动态生成!!! 。 整个游戏不许出现Find
游戏对象,SendMessage
这类突破程序结构的 通讯耦合 语句。 违背本条准则,不给分- 下图为项目的游戏对象结构,其中
main
为空对象,挂载FirstController
的脚本来动态实例所有的游戏对象。
- 下图为项目的游戏对象结构,其中
-
请使用课件架构图编程,不接受非
MVC
结构程序 -
注意细节,例如:船未靠岸,牧师与魔鬼上下船运动中,均不能接受用户事件!
-
-
游戏成品(动图高糊)
-
游戏结束的判断
-
某陆地魔鬼多于牧师
-
超时
-
-
行进中
- 无法在船行进时点击或者对岸上船
- 无法在船行进时点击或者对岸上船
-
游戏取胜的判断
- 左陆地只要有三个牧师即可通关
- 左陆地只要有三个牧师即可通关
-
-
代码
-
MVC
结构:Model Controller View -
类图:
-
类图中的细节:
- 对于每类游戏对象,我们可以为其建立模型,比如牧师或魔鬼的模型
RoleModel
,该类会封装一些状态量,比如是否在船上、在左侧还是右侧等,还有加载预制并且实例化的方法。 - 除了一些在游戏中静态不变的类(比如
PositionModel
存储初始位置信息,RiverModel
与玩家没有交互功能),其他的模型都会有相应的控制器,用来控制模型的数据变化,这些控制器往上被场景控制器FirstController
控制。除此之外,角色与船是可以响应玩家的点击动作的,所以每个RoleModel/BoatMode
实例会添加Click
组件,用于响应用户的点击。 - 最后在视图上,上一点提到的
RoleModelController
与BoatModelController
都会将角色的移动或者船的移动这种视图变化转交给IUserAction
接口来处理,IUserAction
的具体实现类是UserGUI
。
- 对于每类游戏对象,我们可以为其建立模型,比如牧师或魔鬼的模型
-
代码仓库:由于代码太长,就不全部贴上来了,具体的设计思路就是根据上面的
MVC
框架来实现每个模型、控制器。想要查看代码细节可以到gitee仓库中查看。
-
-
实验中遇到的小困难的解决
-
子物体相对于父物体的位置:
-
以我们游戏预制作为讨论对象,我们知道在船上和陆地上会将物体作为子物体的形式存在,这有便于移动船的时候将物体也进行移动。那么我们就要处理好世界坐标和相对坐标两个概念。
-
如下图所示,是游戏在世界坐标系下的长度图示:
-
如果我们将一个物体拖入到另一物体中,后者成为父物体,其的
Scale
将会起到重要作用,由于前者需要在后者的坐标系中确定position
和scale
(此时与世界坐标系无关系)并且还要保持我们在世界坐标系中看到正确的长度,所以我们必须要将子物体的Scale
以父物体的Scale
刻度来重新计算。
-
如上图所示,世界坐标系中12单位的长度在本父物体的坐标系中只是1单位,所以陆地上角色的中心距离陆地的中心的相对距离在世界坐标为3.5单位,但在父物体也即陆地的坐标系中仅仅是(3.5/5 = 0.7)单位。
-
-
正确地设置不同UI控件地字体大小:
- 之所以要强调正确,是因为我找到很多博客写的调节空间字体大小的都如下所示
不负责任:
乍看之下,感觉没毛病,但同样的代码要是直接运用到GUIStyle style = new GUIStyle {fontSize = 25}; GUI.Label(new Rect(0, 0, 100, 50), "SEEMS_OK", style);
Button
,你会发现按钮就只剩下字体,背景和边框都没了。正确的方式应该是尽量保证所改变属性之外的样式能够继续保持。
这样子我们就能够看到按钮的字体变大而其他样式保持不变。但是我们很快就会发现,我们int memo = GUI.skin.GetStyle("button").fontSize; // 记录按钮的默认字体大小 GUI.skin.GetStyle("button").fontSize = 30; // 改为期望值 GUI.Button(new Rect(0, 0, 50, 50), "RIGHT!"); GUI.skin.GetStyle("button").fontSize = memo; // 恢复原来的值
GetStyle
改的是系统的全局样式,也就是说,当你第二次运行游戏时,memo
早就记忆着你最近的一次改变,同时我们不管任何Button
都会使用这个skin
。于是最后我想到应该考虑如何克隆一个skin
副本,但其实这还不如仅拷贝GUIStyle
,于是我们有了最后的解决方案:
可以看到,上面的获取默认按钮样式的方式十分简单,竟然只要传// 获取按钮默认样式的拷贝 GUIStyle myBtn = new GUIStyle("button"); myBtn.fontSize = 25; // 指定按钮为修改过后的按钮样式 GUI.Button(new Rect(0, 0, 50, 50), "EASY!!", myBtn); // 下面的按钮仍旧使用的是默认的按钮样式 GUI.Button(new Rect(50, 50, 50, 50), "DEFAULT");
strng
即可??其实官方文档并没有直接传string
这个API
,或许可以在后续的博客讲解下这是如何做到的,先卖个关子。
- 之所以要强调正确,是因为我找到很多博客写的调节空间字体大小的都如下所示
-
简便的切换主相机视角的方式:
- 作为新手,时常烦恼如何调整主相机到我们想要的视角,于是我们在相机移动和旋转之间来回
磨蹭,这样的确是效率很低,除了主相机,还有很多关于方向的游戏对象也有一样的问题,比如风区(Wind Zone
)或者平行光线。 - 救星: 我们习惯使用手工具和
Alt
+拖拽来移动来将我们的Scene
场景来到想要的角度,但是这是主相机并不停留在该视角前,但是我们只需要:- 在
Hierarchy
视图中选中主相机(或者风区、光线); - 按下快捷键
Ctrl + Shift + F
或者 点击菜单栏中GameObject
并点击Align With View
- 这样就完成了主相机视角的切换了。
- 在
- 作为新手,时常烦恼如何调整主相机到我们想要的视角,于是我们在相机移动和旋转之间来回
-
3.
思考题【选做】
[Q]
使用向量与变换,实现并扩展Tranform
提供的方法,如Rotate
、RotateAround
等。- 实现
Rotate
:private void Rotate(Transform trans, Vector3 eulers) { trans.rotation *= Quaternion.Euler(eulers); } // 另一个重载的版本 private void Rotate(Transform trans, Vector3 axis, float angles) { trans.rotation *= Quaternion.AngleAxis(angles, axis); }
- 实现
RotateAround
:private void RotateAround(Transform trans, Vector3 point, Vector3 axis, float angles) { var rotate = Quaternion.AngleAxis(angles, axis); var diff = trans.position - point; diff = rotate * diff; trans.position = point + diff; trans.rotation *= rotate; }
- 检测效果,首先我在三个位置分别放置了两个同样的立方体,我分别测试三个上面的函数,对于每一个测试,涉及两个物体,并且其中一个物体使用系统自带的
Rotate/RotateAround
,另一个则使用我实现的,如果我在游戏界面只看到三个活动的物体(即每组运动的物体重合了)则基本验证我的实现正确(下面提供的是主要代码):void Start() { cubes = new GameObject[6]; for (int i = 0; i < 6; i++) { cubes[i] = GameObject.CreatePrimitive(PrimitiveType.Cube); if (i < 2) { cubes[i].transform.position = new Vector3(0, 0, 0); } else if (i < 4) { cubes[i].transform.position = new Vector3(5, 0, 0); } else { cubes[i].transform.position = new Vector3(10, 0, 0); } } } void Update() { // 第一组测试 cubes[0].transform.Rotate(new Vector3(35, 45, 55) * Time.deltaTime); Rotate(cubes[1].transform, new Vector3(35, 45, 55) * Time.deltaTime); // 第二组测试 cubes[2].transform.Rotate(new Vector3(1, 1, 5), 45 * Time.deltaTime); Rotate(cubes[3].transform, new Vector3(1, 1, 5), 45 * Time.deltaTime); // 第三组测试 cubes[4].transform.RotateAround(new Vector3(1, 2, 5), new Vector3(1, 6, 9), 45 * Time.deltaTime); RotateAround(cubes[5].transform, new Vector3(1, 2, 5), new Vector3(1, 6, 9), 45 * Time.deltaTime); }
- 运行效果:
与预期相符合。
- 实现