在之前的这一篇博客文章中(www.yizuodi.cn/archives/129)，我们进行了UI 效果制作。在另一篇文章中(www.yizuodi.cn/archives/119)，我们制作了一款智能巡逻兵游戏。本篇文章，我们做了一个简单的用户登录实现，来尝试让我们的智能巡逻兵游戏变成一款网络游戏。为什么要做“用户登录”的部分？对于我们之前的智能巡逻兵游戏中玩家得分的设计，我个人的想法是将得分和通关用时综合考虑。我们分析，得分为躲避得分（成功甩掉计一分）。任务点是随机刷新的，而较难完成的任务点会导致玩家“用时增加，得分增加”概率提升。所以我们可以将游戏得分和通关用时加权求和得到一个分数（类似与很多游戏的“段位分”），来作为玩家游戏成果的评估。“巡逻兵游戏”是一个单机游戏，那么我们怎么通过所谓“段位分”给予玩家满足感呢？我个人的想法是提供一个全球游戏榜单（我制作了一个示例game.233521.xyz）。为此，我们需要一个为玩家提供一个账号系统，需要一个服务端来让所有玩家可以提交他们的游戏结果。我们的设计1.在游戏中，我们有一个登录的UI，玩家点击按钮可以唤起浏览器打开网页进行登录（可拓展微信扫码登录等）。2.登录成功后可以获取到一个一次性登录令牌，玩家复制后在游戏中粘贴，点击登录按钮。若令牌有效即可成功登入。实现效果展示游戏中的登录的UI如下：唤起浏览器打开的登录页面如下(demo)：登录后玩家获取令牌：本人的“3D游戏编程与设计”系列合集，请访问：https://www.yizuodi.cn/category/3DGame/
本文内容为坦克对战游戏AI设计。我们构建 AI 对战坦克有几点目标：· 使用“感知-思考-行为”模型，建模 AI 坦克· 场景中要放置一些障碍阻挡对手视线· 坦克要放置一个矩阵包围盒触发器，保证 AI 坦克能使用射线探测对手方位· AI 坦克必须在有目标条件下使用导航，并能绕过障碍。· 实现人机对战我们使用了资源Tanks! Tutorial（Unity资源商店链接）。接下来我们构建AI对战坦克。游戏设计玩家操纵红色坦克，可以通过WASD按键移动坦克，按空格键发射弹药攻击。地图中有一些蓝色的AI坦克，当玩家出现在AI坦克的视野中，它们就会跟踪玩家并向玩家发射弹药。每辆坦克都有自己的血量值，受到攻击坦克血量会下降，当血量值将为0以下，则坦克被击毁。如果玩家的坦克被击毁则游戏失败，如果玩家击毁了所有的AI坦克则游戏胜利。对于“感知-思考-行为”模型——AI坦克会感知周围是否有玩家存在，进行思考:1.没有玩家，那么就进行行动巡逻。2.有玩家，那么就进行行动追捕。继续进行感知：1.若玩家进入AI坦克的射击范围，则进行行动射击。2.若玩家没有进入AI坦克的射击范围，则继续进行行动追捕。游戏展示场景布置我们先简单布置场景：NavMesh是unity提供的导航寻路功能。我们先将地图设置为“navigation static”，然后选中，选择最顶部的窗口-AI-导航。在Navigation窗口给地图中的各个对象设置walkable或者not walkable等，然后进行bake烘培，得到描述游戏对象可行走的表面的数据结构Navigation Mesh。可通过这些三角网格计算其中任意两点之间的最短路径用于游戏对象的导航，作为“感知-思考-行为”模型中的“感知”。然后我们制作三种预制件——敌方（AI）坦克、玩家坦克和炮弹。脚本编写我们考虑游戏的双方：AI坦克和玩家坦克的共性——都有血量。因而我们可以先定义一个共同的基类Tank如下：public class Tank : MonoBehaviour { private float hp; // 血量 public float getHp() // 获取血量 { return hp; // 返回血量 } public void setHp(float hp) // 设置血量 { this.hp = hp; // 设置血量 } }然后对于坦克的一些事件，我们有接口类IUserAction，包含坦克前进、后退、转向、射击和游戏状态获取：public interface IUserAction { void moveForward(); void moveBack(); void turn(float offsetX); void shoot(); bool isGameOver(); }我们有一个场景控制器SceneController来掌控全局。因此对于玩家的坦克PlayerTank，我们只需要在其发现自己血量低于零时，销毁自己并发布相应事件共控制器处理即可：public class PlayerTank : Tank { public delegate void destroy(); public static event destroy destroyEvent; void Start (){ setHp(1000f); } void Update (){ if(getHp() <= 0){ this.gameObject.SetActive(false); if (destroyEvent != null) destroyEvent(); } } }而对于AI坦克，我们有“思考”。如果其在自己附近没有发现玩家，则进行巡逻。若发现了玩家则开始追捕；一旦追捕使得玩家出现在其一定范围内（射程内），则其开炮射击。这一系列就是对应“行动”。using UnityEngine.AI; public class AITank : Tank { public delegate void recycle(GameObject tank); // 回收事件委托 public static event recycle recycleEvent; // 回收事件 private Vector3 target; // 目标点 private bool gameover; // 游戏结束状态 private static Vector3[] points = { new Vector3(37.6f,0,0), new Vector3(40.9f,0,39), new Vector3(13.4f, 0, 39), new Vector3(13.4f, 0, 21), new Vector3(0,0,0), new Vector3(-20,0,0.3f), new Vector3(-20, 0, 32.9f), new Vector3(-37.5f, 0, 40.3f), new Vector3(-37.5f,0,10.4f), new Vector3(-40.9f, 0, -25.7f), new Vector3(-15.2f, 0, -37.6f), new Vector3(18.8f, 0, -37.6f), new Vector3(39.1f, 0, -18.1f) }; // AI坦克初始化的目标点 private int destPoint = 0; private NavMeshAgent agent; // NavMeshAgent组件 private bool isPatrol = false; // 是否巡逻 private void Awake() { destPoint = UnityEngine.Random.Range(0, 13); // 随机选择一个点 } void Start () { setHp(100f); StartCoroutine(shoot()); // 开启协程 agent = GetComponent<NavMeshAgent>(); // 获取NavMeshAgent组件 } private IEnumerator shoot() { while (!gameover) // 游戏未结束 { for(float i = 1; i > 0; i -= Time.deltaTime) { yield return 0; } if(Vector3.Distance(transform.position, target) < 20) // 如果距离目标点小于20 { GameObjectFactory mf = Singleton<GameObjectFactory>.Instance; // 获取工厂单例 GameObject bullet = mf.getBullet(tankType.Enemy); // 从工厂中获取子弹 bullet.transform.position = new Vector3(transform.position.x, 1.5f, transform.position.z) + transform.forward * 1.5f; // 设置子弹的位置 bullet.transform.forward = transform.forward; // 设置子弹的方向 Rigidbody rb = bullet.GetComponent<Rigidbody>(); // 获取子弹的刚体组件 rb.AddForce(bullet.transform.forward * 20, ForceMode.Impulse); // 给子弹一个向前的力 } } } void Update () { gameover = Director.getInstance().currentSceneController.isGameOver(); // 获取游戏结束状态 if (!gameover) { target = Director.getInstance().currentSceneController.getPlayerPos(); // 获取玩家坦克的位置 if (getHp() <= 0 && recycleEvent != null) // 如果血量小于等于0 { recycleEvent(this.gameObject); // 回收坦克 } else { if(Vector3.Distance(transform.position, target) <= 30) // 如果距离目标点小于等于30 { isPatrol = false; // 不巡逻 agent.autoBraking = true; // 自动减速 agent.SetDestination(target); // 设置目标点 } else { patrol(); // 巡逻 } } } else { NavMeshAgent agent = GetComponent<NavMeshAgent>(); // 获取NavMeshAgent组件 agent.velocity = Vector3.zero; // 坦克停止 agent.ResetPath(); // 重置路径 } } private void patrol() { if(isPatrol) // 如果正在巡逻 { if(!agent.pathPending && agent.remainingDistance < 0.5f) // 如果路径没有挂起并且剩余距离小于0.5 GotoNextPoint(); // 前往下一个点 } else { agent.autoBraking = false; // 不自动减速 GotoNextPoint(); // 前往下一个点 } isPatrol = true; } private void GotoNextPoint() { agent.SetDestination(points[destPoint]); // 设置目标点 destPoint = (destPoint + 1) % points.Length; // 选择下一个点 } }为了生成坦克（和子弹），我们有工厂类Factory来进行分配和回收：public enum tankType : int { Player, Enemy } // 坦克类型 public class Factory : MonoBehaviour { public GameObject player; // 玩家坦克 public GameObject tank; // AI坦克 public GameObject bullet; // 子弹 public ParticleSystem ps; // 爆炸特效 private Dictionary<int, GameObject> usingTanks; // 使用中的坦克 private Dictionary<int, GameObject> freeTanks; // 未使用的坦克 private Dictionary<int, GameObject> usingBullets; // 使用中的子弹 private Dictionary<int, GameObject> freeBullets; // 未使用的子弹 private List<ParticleSystem> psContainer; // 爆炸特效容器 private void Awake() { usingTanks = new Dictionary<int, GameObject>(); freeTanks = new Dictionary<int, GameObject>(); usingBullets = new Dictionary<int, GameObject>(); freeBullets = new Dictionary<int, GameObject>(); psContainer = new List<ParticleSystem>(); } void Start () { AITank.recycleEvent += recycleTank; // 注册回收坦克事件 } public GameObject getPlayer() { return player; // 返回玩家坦克 } public GameObject getTank() { if(freeTanks.Count == 0) // 如果未使用的坦克数量为0 { GameObject newTank = Instantiate<GameObject>(tank); // 实例化坦克 usingTanks.Add(newTank.GetInstanceID(), newTank); // 添加到使用中的坦克 newTank.transform.position = new Vector3(Random.Range(-100, 100), 0, Random.Range(-100, 100)); // 设置坦克位置 return newTank; // 返回坦克 } foreach (KeyValuePair<int, GameObject> pair in freeTanks) // 遍历未使用的坦克 { pair.Value.SetActive(true); // 设置坦克为激活状态 freeTanks.Remove(pair.Key); // 从未使用的坦克中移除 usingTanks.Add(pair.Key, pair.Value); // 添加到使用中的坦克 pair.Value.transform.position = new Vector3(Random.Range(-100, 100), 0, Random.Range(-100, 100)); // 设置坦克位置 return pair.Value; // 返回坦克 } return null; } public GameObject getBullet(tankType type) // 获取子弹 { if (freeBullets.Count == 0) // 如果未使用的子弹数量为0 { GameObject newBullet = Instantiate(bullet); // 实例化子弹 newBullet.GetComponent<Bullet>().setTankType(type); // 设置子弹类型 usingBullets.Add(newBullet.GetInstanceID(), newBullet); // 添加到使用中的子弹 return newBullet; // 返回子弹 } foreach (KeyValuePair<int, GameObject> pair in freeBullets) // 遍历未使用的子弹 { pair.Value.SetActive(true); // 设置子弹为激活状态 pair.Value.GetComponent<Bullet>().setTankType(type); // 设置子弹类型 freeBullets.Remove(pair.Key); // 从未使用的子弹中移除 usingBullets.Add(pair.Key, pair.Value); // 添加到使用中的子弹 return pair.Value; // 返回子弹 } return null; } public ParticleSystem getPs() { for(int i = 0; i < psContainer.Count; i++) { if (!psContainer[i].isPlaying) return psContainer[i]; // 如果特效未播放，返回特效 } ParticleSystem newPs = Instantiate<ParticleSystem>(ps); // 实例化特效 psContainer.Add(newPs); // 添加到特效容器 return newPs; // 返回特效 } public void recycleTank(GameObject tank) { usingTanks.Remove(tank.GetInstanceID()); // 从使用中的坦克中移除 freeTanks.Add(tank.GetInstanceID(), tank); // 添加到未使用的坦克 tank.GetComponent<Rigidbody>().velocity = new Vector3(0, 0, 0); // 设置坦克速度为0 tank.SetActive(false); // 设置坦克为未激活状态 } public void recycleBullet(GameObject bullet) { usingBullets.Remove(bullet.GetInstanceID()); // 从使用中的子弹中移除 freeBullets.Add(bullet.GetInstanceID(), bullet); // 添加到未使用的子弹 bullet.GetComponent<Rigidbody>().velocity = new Vector3(0, 0, 0); // 设置子弹速度为0 bullet.SetActive(false); // 设置子弹为未激活状态 } }我们的子弹Bullet用完即回收：public class Bullet : MonoBehaviour { public float explosionRadius = 3f; // 爆炸半径 private tankType type; // 子弹类型(whois) public void setTankType(tankType type){ this.type = type; // 设置子弹类型(whois) } private void Update() { if(this.transform.position.y < 0 && this.gameObject.activeSelf){ Factory mf = Singleton<Factory>.Instance; // 获取工厂 ParticleSystem explosion = mf.getPs(); // 获取粒子系统 explosion.transform.position = transform.position; // 设置粒子系统位置 explosion.Play(); // 播放粒子系统 mf.recycleBullet(this.gameObject); // 回收子弹 } } void OnCollisionEnter(Collision other) { Factory mf = Singleton<Factory>.Instance; // 获取工厂 ParticleSystem explosion = mf.getPs(); // 获取粒子系统 explosion.transform.position = transform.position; // 设置粒子系统位置 Collider[] colliders = Physics.OverlapSphere(transform.position, explosionRadius); // 获取爆炸范围内的所有碰撞体 for(int i = 0; i < colliders.Length; i++) // 遍历所有碰撞体 if(colliders[i].tag == "tankPlayer" && this.type == tankType.Enemy || colliders[i].tag == "tankEnemy" && this.type == tankType.Player) { // 如果碰撞体是敌人且子弹是玩家的，或者碰撞体是玩家且子弹是敌人的 float distance = Vector3.Distance(colliders[i].transform.position, transform.position); // 被击中坦克与爆炸中心的距离 float hurt = 100f / distance; float current = colliders[i].GetComponent<Tank>().getHp(); // 获取被击中坦克的当前血量 colliders[i].GetComponent<Tank>().setHp(current - hurt); // 设置被击中坦克的血量 } explosion.Play(); // 播放粒子系统 if (this.gameObject.activeSelf) mf.recycleBullet(this.gameObject); // 回收子弹 } }我们有导演类Director和单例类Singleton，它们和之前作业中的一样。场景控制器SceneController调用Factory类初始化PlayerTank、AITank和Bullet，还有粒子系统等等游戏中的对象。IUserAction接口中的函数也在这里实现供“坦克”中调用。public class SceneController : MonoBehaviour, IUserAction { public GameObject player; // 玩家坦克 private bool gameOver = false; // 游戏状态 private int enemyCount = 6; // AI坦克数量 private Factory mf; // 工厂 private MainCameraControl cameraControl; // 相机控制 private void Awake() { Director director = Director.getInstance(); // 获取导演 director.currentSceneController = this; // 设置当前场景控制器 mf = Singleton<Factory>.Instance; // 获取工厂 player = mf.getPlayer(); // 获取玩家坦克 cameraControl = GetComponent<MainCameraControl>(); // 获取相机控制器 cameraControl.setTarget(player.transform); // 设置相机跟随目标 } void Start () { for(int i = 0; i < enemyCount; i++) { GameObject gb = mf.getTank(); // 使用工厂生成若干AI坦克 cameraControl.setTarget(gb.transform); // 设置相机跟随目标 } PlayerTank.destroyEvent += setGameOver; // 注册玩家坦克销毁事件 cameraControl.SetStartPositionAndSize(); // 设置相机的初始位置和尺寸 } void Update () { Camera.main.transform.position = new Vector3(player.transform.position.x, 15, player.transform.position.z); // 相机跟随玩家坦克 } public Vector3 getPlayerPos() { return player.transform.position; // 获取玩家坦克的位置 } public bool isGameOver() { return gameOver; // 获取游戏状态 } public void setGameOver() { gameOver = true; // 设置游戏结束 } public void moveForward() // 玩家坦克前进 { player.GetComponent<Rigidbody>().velocity = player.transform.forward * 20; } public void moveBack() // 玩家坦克后退 { player.GetComponent<Rigidbody>().velocity = player.transform.forward * -20; } public void turn(float offsetX) { float y = player.transform.localEulerAngles.y + offsetX * 5; float x = player.transform.localEulerAngles.x; player.transform.localEulerAngles = new Vector3(x, y, 0); } public void shoot() { GameObject bullet = mf.getBullet(tankType.Player); // 使用工厂生成子弹 bullet.transform.position = new Vector3(player.transform.position.x, 1.5f, player.transform.position.z) + player.transform.forward * 1.5f; // 设置子弹位置 bullet.transform.forward = player.transform.forward; // 设置子弹方向 Rigidbody rb = bullet.GetComponent<Rigidbody>(); // 获取子弹刚体 rb.AddForce(bullet.transform.forward * 20, ForceMode.Impulse); // 给子弹施加力 } }最后我们有UserOperater类来获取玩家的输入，从而通过IUserAction执行场景控制类中定义的行为：public class UserOperater : MonoBehaviour { IUserAction action; void Start () { action = Director.getInstance().currentSceneController as IUserAction; } void Update () { if (!action.isGameOver()) { if (Input.GetKey(KeyCode.W)) { action.moveForward(); } if (Input.GetKey(KeyCode.S)) { action.moveBack(); } if (Input.GetKeyDown(KeyCode.Space)) { action.shoot(); } float offsetX = Input.GetAxis("Horizontal"); action.turn(offsetX); } } }其中“Horizontal”需要我们在项目设置-输入管理器中做相应的设置，我这里设置的是按照玩家习惯的键盘A和D键。我们的主摄像机会根据地图中坦克的数量来确定视野大小，并随着玩家的操作移动位置，保证玩家可以看到地图中大多数的AI坦克，做出决策。参考https://yuandi-sherry.github.io/2018/06/19/Week15-Tanks/本人的“3D游戏编程与设计”系列合集，请访问：https://www.yizuodi.cn/category/3DGame/
本文主要涉及UI效果制作——UGUI实现Quest Log公告牌效果。进入 NGUI 官方网站，有以下效果• Inventory 背包系统• Quest Log 公告牌• Scroll View 选择板注：以上例子需要使用 Unity web player， 仅支持以下操作系统与浏览器，参见官方下载• Windows 版 IE11• Mac OS X 10.7 Safari注：出现界面需要等待较长时间，让它慢慢加载NGUI提供的示例如下图：而UGUI是由unity官方提供的UI工具套件。参考它们的区别：https://www.jianshu.com/p/5891e28aa766场景布置我们可以通过创建UI-面板创建一个panel（面板），其根目录为canvas（画布）。然后我们在panel中创建一个文本，作为公告栏的标题；在panel中添加一个ScollView（滚动视图）作为公告栏的主要内容部分；在panel中添加一个button（按钮）作为公告栏“关闭”按钮。略作调整后效果如图（这里我们使用了2D Casual UI资源包）：滚动视图的完善上面我们的UI界面中，只有滚动视图，里面还没有详细的内容，我们继续为其添加详细的内容。我们先在Viewport的Content中加入组件“Vertical Layout Group”，设置勾选“控制子对象大小-宽度”和“子力拓展-宽度”，其他不勾选。然后我们在Content下创建三个Button（命名为Button1，Button2，Button3）和三个Text（命名为Text1，Text2，Text3）。在Button的检查器中，我们将导航设置为None。我们需要为按钮加上脚本，使其被点击时，对应的文本展示/不展示。脚本extend内容如下：using UnityEngine.UI; public class extend : MonoBehaviour { public Text text; // 控制的Text对象 private int frame = 20; // 动画帧数 void Start() { Button btn = this.gameObject.GetComponent (); // 获取Button对象 btn.onClick.AddListener(TaskOnClick); // 添加点击事件 text.gameObject.SetActive(false); // 初始化时隐藏Text } IEnumerator FoldAndUnfold(bool isFold) { Vector2 fold = new Vector2(0, 120); // 折叠状态 Vector2 unfold = new Vector2(-90, 0); // 展开状态 Vector2 target = isFold ? fold : unfold; for (int i = 0; i < frame; i++) // 逐帧执行动画 { target.x += (isFold ? -90f : 90f) / frame; // 旋转角度逐帧增加或减少 target.y += (isFold ? -120f : 120f) / frame; // Text高度逐帧增加或减少 text.transform.rotation = Quaternion.Euler(target.x, 0, 0); // 设置旋转角度 text.rectTransform.sizeDelta = new Vector2(text.rectTransform.sizeDelta.x, target.y); // 设置Text高度 if (i == 0 || i == frame - 1) // 第一帧和最后一帧控制Text的显示状态 { if(isFold){ text.gameObject.SetActive(false); } else{ text.gameObject.SetActive(true); } } yield return null; } } void TaskOnClick() { if (text.gameObject.activeSelf) // 判断Text是否显示 { StartCoroutine(FoldAndUnfold(true)); // 执行折叠动画 } else { StartCoroutine(FoldAndUnfold(false)); // 执行展开动画 } } }这样我们就成功实现了与NGUI中例子类似的效果。效果展示本人的“3D游戏编程与设计”系列合集，请访问：https://www.yizuodi.cn/category/3DGame/
本文使用官方资源 Vehicle 的 car， 使用 Smoke 粒子系统模拟启动发动、运行、故障等场景效果，完善“汽车尾气”模拟场景布置我们需要先导入Unity3D 官方资源包Standard Assets，该资源包已经不再内置，而且资源商店也没有，我提供一份附在这里。Standard Assets.unitypackage（68.4M）网上找来的资源包导入后，可能会有一些问题，可参考：https://blog.csdn.net/Sakuya__/article/details/113815974我们导入资源包后，先在场景中放置一个3D平面对象（plane），然后在资源包中找到Vehicle 的 car的预制，拖入场景中，效果如下图：粒子效果模拟根据要求，我们模拟启动发动、运行、故障三种场景的尾气效果。预制的Car里面的Particles里有一个Smoke粒子系统ParticleBurnoutSmoke。我们的粒子系统叫做start、run和damage。在检查器中，粒子系统有很多设置选项，我们可以根据自己的需要选择。由于Standard Assets资源包内置了一些材质，我们可以在粒子系统的渲染器（render）-材质（material）选项中选用，从而让粒子有不同的形状。为了展示不同的尾气效果，我们编写脚本CarStatus加到Car这个游戏对象上，功能为按下键盘上的数字选择尾气的效果（数字1-4）。public class CarStatus : MonoBehaviour { // 定义需要切换的粒子效果 public ParticleSystem[] particleSystems; // 当前粒子效果的索引 public int currentIndex = 0; void Start() { particleSystems = new ParticleSystem[4]; particleSystems[0] = transform.Find("Particles/ParticleBurnoutSmoke").GetComponent<ParticleSystem>(); particleSystems[1] = transform.Find("Particles/start").GetComponent<ParticleSystem>(); particleSystems[2] = transform.Find("Particles/run").GetComponent<ParticleSystem>(); particleSystems[3] = transform.Find("Particles/damage").GetComponent<ParticleSystem>(); // 将所有粒子效果关闭 for (int i = 0; i < particleSystems.Length; i++) { particleSystems[i].Stop(); } } void Update() { // 当按下键盘对应数字键时，切换粒子效果 if (Input.GetKeyDown(KeyCode.Alpha1)) { // 将当前粒子效果关闭 particleSystems[currentIndex].Stop(); // 将当前粒子效果索引设置为0 currentIndex = 0; // 将当前粒子效果打开 particleSystems[currentIndex].Play(); } else if (Input.GetKeyDown(KeyCode.Alpha2)) { particleSystems[currentIndex].Stop(); currentIndex = 1; particleSystems[currentIndex].Play(); } else if (Input.GetKeyDown(KeyCode.Alpha3)) { particleSystems[currentIndex].Stop(); currentIndex = 2; particleSystems[currentIndex].Play(); } else if (Input.GetKeyDown(KeyCode.Alpha4)) { particleSystems[currentIndex].Stop(); currentIndex = 3; particleSystems[currentIndex].Play(); } } }为了方便观察，我们把之前项目中CameraMove脚本也拿来稍作修改，附加到主摄像头上。public class CameraMove : MonoBehaviour { public GameObject follow; // 跟随的对象 public float speed = 8f; // 相机跟随的速度 Vector3 offset; // 相机与物体相对偏移位置 void Start() { follow = GameObject.Find("Car"); offset = transform.position - follow.transform.position; } void FixedUpdate() { Vector3 target = follow.transform.position + offset; // 计算目标位置 transform.position = Vector3.Lerp(transform.position, target, speed * Time.deltaTime); // 摄像机自身位置到目标位置平滑过渡 } }粒子效果展示当按下“2”键，展示启动发动场景当按下“3”键，展示运行场景当按下“4”键，展示故障场景本人的“3D游戏编程与设计”系列合集，请访问：https://www.yizuodi.cn/category/3DGame/
游戏设计： • 创建一个地图和若干巡逻兵(使用动画)；• 每个巡逻兵走一个3~5个边的凸多边型，位置数据是相对地址。即每次确定下一个目标位置，用自己当前位置为原点计算； • 巡逻兵碰撞到障碍物，则会自动选下一个点为目标； • 巡逻兵在设定范围内感知到玩家，会自动追击玩家； • 失去玩家目标后，继续巡逻； • 计分：玩家每次甩掉一个巡逻兵计一分，与巡逻兵碰撞游戏结束； 程序设计： • 使用订阅与发布模式传消息• 工厂模式生产巡逻兵游戏设计基于上面的几点，我们设计这样一个游戏场景：1.我们的地图有3×3共计9块区域，不同区域间有部分连通，每个区域有一个巡逻兵。2.地图上会随机生成一些任务点，作为玩家需要到达的目标位置，这些任务点需要玩家全部到达且未被巡逻兵碰撞到方为获胜。3.每到达一个任务点时为甩掉巡逻兵一次，计一分。4.每次游戏任务点位置随机分布在地图上，但总的任务点数量一定。5.玩家初始位置为地图区域的中心位置。游戏展示游戏操作演示1-游戏各项操作（11.7M）游戏操作演示2-失败（1.17M）游戏操作演示3-完成（0.9M）游戏预制制作基于上面的要求与设计，可以先为我们的游戏场景创建4个预制：地图、玩家、“任务点”、巡逻兵。地图我使用了Low Poly v2资源包进行预制制作。玩家和巡逻兵我基于SoldierFree资源包中的预制制作，我们为两个预制添加Rigidbody和Capsule Collider来实现玩家与巡逻兵的碰撞。对于巡逻兵的预制，我们添加了两个脚本，一个脚本用于记录巡逻兵对象的一些信息，另一个脚本用于处理玩家与巡逻兵相撞后的情况。另外我们添加了一块方形的碰撞器（设置为触发器）来探测玩家，并添加了相应的脚本来使得有玩家进入/离开该方形区域时，巡逻兵开始/停止追捕玩家。我们使用了Animator组件，通过控制器来使得对应的对象做出相应的动画行为。它是使用状态机来管理运动的，我们在脚本中可以设置状态的改变，从而对应有对象（玩家或巡逻兵）的动画表现。然后我们将玩家对象和巡逻兵对象对应的控制器加到预制上。“任务点”目前是简单的用红色圆球来代替（其碰撞体积也为圆球）。这样我们就拥有了一些游戏的基本元素。脚本编写我们的实现要求是：订阅与发布模式传消息和工厂模式生产巡逻兵。我们先给出主摄像机上附加脚本CameraMove，从而我们可以在移动玩家游戏对象时伴随镜头的移动。public class CameraMove : MonoBehaviour { public GameObject follow; // 跟随的对象 public float speed = 5f; // 相机跟随的速度 Vector3 offset; // 相机与物体相对偏移位置 void Start() { offset = transform.position - follow.transform.position; } void FixedUpdate() { Vector3 target = follow.transform.position + offset; // 计算目标位置 transform.position = Vector3.Lerp(transform.position, target, speed * Time.deltaTime); // 摄像机自身位置到目标位置平滑过渡 } }对于我们的巡逻兵，我们有一个脚本PatrolData附加其上，用于保存它的一些信息public class PatrolData : MonoBehaviour { public int block; // 巡逻兵所在区域编号 public bool follow_player = false; // 是否正在追逐玩家 public int player_block = -1; // 此时玩家所在区域编号 public GameObject player; // 玩家游戏对象 public Vector3 start_position; // 巡逻兵初始位置 }接着我们给出几个预制中与碰撞相关的脚本（里面使用了控制器等部分后续给出）。首先对于场景，我们对9块区域都附加上PlaneCollide脚本，以更新玩家所在的区域信息。public class PlaneCollide : MonoBehaviour { public int block = 0; FirstSceneController sceneController; private void Start() { sceneController = SSDirector.GetInstance().CurrentScenceController as FirstSceneController; } void OnTriggerEnter(Collider collider) { // 标记玩家进入区域 if (collider.gameObject.tag == "Player") { sceneController.player_block = block; } } }然后对于巡逻兵，由于游戏失败的条件是由巡逻兵与玩家相撞，故我们附加脚本PatrolPlayerCollide处理这一情况：public class PatrolPlayerCollide : MonoBehaviour { void OnCollisionEnter(Collision other) { if (other.gameObject.tag == "Player") // 当巡逻兵与玩家相撞 { other.gameObject.GetComponent<Animator>().SetTrigger("death"); // 改变玩家的状态 this.GetComponent<Animator>().SetTrigger("shoot"); // 改变巡逻兵的状态 Singleton<GameEventManager>.Instance.PlayerGameover(); // 游戏结束 } } }巡逻兵还需要对其附近的区域进行探测，当玩家进入其探测区域，巡逻兵会对玩家进行追逐，故我们有脚本PatrolCollide：public class PatrolCollide : MonoBehaviour { void OnTriggerEnter(Collider collider) { if (collider.gameObject.tag == "Player") // 玩家进入巡逻兵探测范围 { this.gameObject.transform.parent.GetComponent<PatrolData>().follow_player = true; this.gameObject.transform.parent.GetComponent<PatrolData>().player = collider.gameObject; } } void OnTriggerExit(Collider collider) { if (collider.gameObject.tag == "Player") // 玩家离开巡逻兵探测范围 { this.gameObject.transform.parent.GetComponent<PatrolData>().follow_player = false; this.gameObject.transform.parent.GetComponent<PatrolData>().player = null; } } }对于碰撞，我们还有一种情况需要处理——当玩家与我们的“任务点”碰撞，“任务点”消失，需要完成的任务数减少。我们有脚本TaskCollide：public class TaskCollide : MonoBehaviour { void OnTriggerEnter(Collider collider) { if (collider.gameObject.tag == "Player" && this.gameObject.activeSelf) { this.gameObject.SetActive(false); Singleton<GameEventManager>.Instance.ReduceTaskNum(); // 减少任务数量 } } }然后我们运用工厂模式生产巡逻兵（和“任务点”），我们有工厂类Factory。可以看到我们巡逻兵的出生点是固定的（玩家出生点也是固定的，避免二者开局就相遇），而任务点是在地图中随机刷新的。public class Factory : MonoBehaviour { private GameObject patrol = null; // 巡逻兵 private List<GameObject> used = new List<GameObject>(); // 正在被使用的巡逻兵 private GameObject task = null; // 任务点 private List<GameObject> usedtask = new List<GameObject>(); // 正在被使用的任务点 private float range = 12; // 任务点生成的坐标范围 private Vector3[] vec = new Vector3[9]; // 用于保存每个巡逻兵的初始位置 public FirstSceneController sceneControler; // 场景控制器 public List<GameObject> GetPatrols() // 生成巡逻兵 { int[] pos_x = { -6, 4, 13 }; int[] pos_z = { -4, 6, -13 }; for(int i=0;i < 3;i++) // 生成不同的巡逻兵初始位置 { for(int j=0;j < 3;j++) { vec[i*3+j] = new Vector3(pos_x[i], 0, pos_z[j]); } } for(int i=0; i < 9; i++) // 依次生成放置巡逻兵 { patrol = Instantiate(Resources.Load<GameObject>("Prefabs/Patrol")); patrol.transform.position = vec[i]; patrol.GetComponent<PatrolData>().block = i + 1; patrol.GetComponent<PatrolData>().start_position = vec[i]; used.Add(patrol); } return used; } public List<GameObject> GetTask() // 生成任务点 { for(int i=0;i<12;i++) { task = Instantiate(Resources.Load<GameObject>("Prefabs/Task")); float ranx = Random.Range(-range, range); float ranz = Random.Range(-range, range); task.transform.position = new Vector3(ranx, 0, ranz); usedtask.Add(task); } return usedtask; } public void StopPatrol() // 游戏结束，使所有巡逻兵停止运动 { for (int i = 0; i < used.Count; i++) { used[i].gameObject.GetComponent<Animator>().SetBool("run", false); // 切换状态 } } }这样我们就定义了工厂类来生成巡逻兵，我们游戏最核心的控制部分为SceneController，这个控制器里我们就调用它来生成了巡逻兵。对于该控制器，我们应该让其最先运行，所以我们要在项目设置-脚本执行顺序中加入它，并把它放在较前的位置，如下图：该控制器串联起了游戏的各个部分，其内容如下：using UnityEngine.SceneManagement; public class SceneController : MonoBehaviour, IUserAction, ISceneController { public Factory patrol_factory; // 巡逻兵工厂 public ScoreRecorder recorder; // 记录得分 public PatrolActionManager action_manager; // 运动管理器 public int player_block = -1; // 当前玩家所处格子编号 public GameObject player; // 玩家 public Camera main_camera; // 主相机 public float player_speed = 5; // 玩家移动速度 public float rotate_speed = 135f; // 玩家旋转速度 private List<GameObject> patrols; // 地图中巡逻兵列表 private List<GameObject> tasks; // 地图中任务列表 private bool game_over = false; // 游戏结束状态 void Update() { for (int i = 0; i < patrols.Count; i++) // 更新所有巡逻兵掌握的玩家区位信息 { patrols[i].gameObject.GetComponent<PatrolData>().player_block = player_block; } if(recorder.GetTaskNumber() == 0) // 任务全部完成 { Gameover(); } } void Start() { SSDirector director = SSDirector.GetInstance(); director.CurrentScenceController = this; patrol_factory = Singleton<Factory>.Instance; action_manager = gameObject.AddComponent<PatrolActionManager>() as PatrolActionManager; LoadResources(); main_camera.GetComponent<CameraMove>().follow = player; recorder = Singleton<ScoreRecorder>.Instance; } public void LoadResources() { Instantiate(Resources.Load<GameObject>("Prefabs/Plane")); player = Instantiate(Resources.Load("Prefabs/Player"), new Vector3(0, 9, 0), Quaternion.identity) as GameObject; tasks = patrol_factory.GetTask(); // 生成任务点 patrols = patrol_factory.GetPatrols(); // 生成巡逻兵 for (int i = 0; i < patrols.Count; i++) // 使所有巡逻兵移动 { action_manager.GoPatrol(patrols[i]); } } public void MovePlayer(float translationX, float translationZ) // 玩家移动 { if(!game_over) { if (translationX != 0 || translationZ != 0) { player.GetComponent<Animator>().SetBool("run", true); } else { player.GetComponent<Animator>().SetBool("run", false); } //移动和旋转 player.transform.Translate(0, 0, translationZ * player_speed * Time.deltaTime); player.transform.Rotate(0, translationX * rotate_speed * Time.deltaTime, 0); //防止碰撞带来的移动 if (player.transform.localEulerAngles.x != 0 || player.transform.localEulerAngles.z != 0) { player.transform.localEulerAngles = new Vector3(0, player.transform.localEulerAngles.y, 0); } if (player.transform.position.y != 0) { player.transform.position = new Vector3(player.transform.position.x, 0, player.transform.position.z); } } } public int GetScore() { return recorder.GetScore(); } public int GetTaskNumber() { return recorder.GetTaskNumber(); } public bool GetGameover() { return game_over; } public void Restart() { SceneManager.LoadScene("Scenes/mySence"); } void OnEnable() { GameEventManager.ScoreChange += AddScore; GameEventManager.GameoverChange += Gameover; GameEventManager.TaskChange += ReduceTaskNumber; } void OnDisable() { GameEventManager.ScoreChange -= AddScore; GameEventManager.GameoverChange -= Gameover; GameEventManager.TaskChange -= ReduceTaskNumber; } void ReduceTaskNumber() { recorder.ReduceTask(); } void AddScore() { recorder.AddScore(); } void Gameover() { game_over = true; patrol_factory.StopPatrol(); action_manager.DestroyAllAction(); } }在我们的场景控制器中，它作为订阅者，订阅了GameEventManager中的事件。有对应的事件发生，场景控制器就会调用相应的方法，GameEventManager部分如下：public class GameEventManager : MonoBehaviour { public delegate void ScoreEvent(); // 得分变化 public static event ScoreEvent ScoreChange; public delegate void GameoverEvent(); // 游戏结束状态变化 public static event GameoverEvent GameoverChange; public delegate void TaskEvent(); // 剩余任务数量变化 public static event TaskEvent TaskChange; public void PlayerEscape() // 玩家逃脱追捕 { if (ScoreChange != null) { ScoreChange(); } } public void PlayerGameover() // 玩家被追到 { if (GameoverChange != null) { GameoverChange(); } } public void ReduceTaskNum() // 剩余任务数量减少 { if (TaskChange != null) { TaskChange(); } } }而像我们上面给出的TaskCollide、PatrolPlayerCollide中，也有消息的发布，它们发布的消息都会被“订阅者”场景控制器接收到做相应的处理。除此之外，我们还有在玩家逃脱巡逻兵追捕时，也会发布消息，使得场景控制器变更得分。这部分涉及到巡逻兵的移动动作——巡逻兵主要在干两件事情：一是巡逻，这时玩家在其探测区域之外（或者并不在该巡逻兵所在的区块）；二是追逐。首先我们有SSAction类作为游戏的动作基类。相应的，我们有动作管理器SSActionManager。我们里面有向订阅者“通报消息”的部分，因此运动状态的变化会影响场景。我们还有游戏的总导演SSDirector。基类的代码我们不再给出，我们给出巡逻兵的运动的实现，这包括三部分：1.巡逻部分的脚本PatrolGoAction:public class PatrolGoAction : SSAction { private enum Dirction { EAST, NORTH, WEST, SOUTH }; private float pos_x, pos_z; // 移动前的x和z方向坐标 private float move_length; // 移动距离长度 private float move_speed = 1.2f; // 移动速度 private bool move_sign = true; // 目的地到达情况 private Dirction dirction = Dirction.EAST; // 移动方向 private PatrolData data; // 巡逻兵数据 private PatrolGoAction() { } public static PatrolGoAction GetSSAction(Vector3 location) { PatrolGoAction action = CreateInstance<PatrolGoAction>(); action.pos_x = location.x; action.pos_z = location.z; action.move_length = Random.Range(4, 7); // 设定移动矩形的边长 return action; } public override void Update() { //防止碰撞发生后的旋转 if (transform.localEulerAngles.x != 0 || transform.localEulerAngles.z != 0) { transform.localEulerAngles = new Vector3(0, transform.localEulerAngles.y, 0); } if (transform.position.y != 0) { transform.position = new Vector3(transform.position.x, 0, transform.position.z); } Gopatrol(); // 巡逻兵移动 if (data.follow_player && data.player_block == data.block) // 如果巡逻兵需要跟随玩家，且玩家在巡逻兵所在区域，停止巡查开始追捕 { this.destroy = true; this.callback.SSActionEvent(this,0,this.gameobject); } } public override void Start() { this.gameobject.GetComponent<Animator>().SetBool("run", true); data = this.gameobject.GetComponent<PatrolData>(); } void Gopatrol() { if (move_sign) { //不需要转向则设定一个目的地，按照矩形移动 switch (dirction) { case Dirction.EAST: pos_x -= move_length; break; case Dirction.NORTH: pos_z += move_length; break; case Dirction.WEST: pos_x += move_length; break; case Dirction.SOUTH: pos_z -= move_length; break; } move_sign = false; } this.transform.LookAt(new Vector3(pos_x, 0, pos_z)); float distance = Vector3.Distance(transform.position, new Vector3(pos_x, 0, pos_z)); if (distance > 0.9) // 当前位置与目的地距离浮点数的比较 { transform.position = Vector3.MoveTowards(this.transform.position, new Vector3(pos_x, 0, pos_z), move_speed * Time.deltaTime); } else { dirction = dirction + 1; // 转向 if(dirction > Dirction.SOUTH) { dirction = Dirction.EAST; } move_sign = true; } } }2.追逐部分的脚本PatrolFollowActionpublic class PatrolFollowAction : SSAction { private float speed = 2f; // 追逐玩家的速度 private GameObject player; // 玩家游戏对象 private PatrolData data; // 巡逻兵数据 private PatrolFollowAction() { } public static PatrolFollowAction GetSSAction(GameObject player) { PatrolFollowAction action = CreateInstance<PatrolFollowAction>(); action.player = player; return action; } public override void Update() { if (transform.localEulerAngles.x != 0 || transform.localEulerAngles.z != 0) { transform.localEulerAngles = new Vector3(0, transform.localEulerAngles.y, 0); } if (transform.position.y != 0) { transform.position = new Vector3(transform.position.x, 0, transform.position.z); } Follow(); if (!data.follow_player || data.player_block != data.block) // 如果巡逻兵无需跟随玩家/玩家不再巡逻兵所在区域，停止追捕开始巡逻 { this.destroy = true; this.callback.SSActionEvent(this,1,this.gameobject); } } public override void Start() { data = this.gameobject.GetComponent<PatrolData>(); } void Follow() { transform.position = Vector3.MoveTowards(this.transform.position, player.transform.position, speed * Time.deltaTime); this.transform.LookAt(player.transform.position); } }3.巡逻兵的动作管理器PatrolActionManagerpublic class PatrolActionManager : SSActionManager { private PatrolGoAction go_patrol; // 巡逻兵巡逻 public void GoPatrol(GameObject patrol) { go_patrol = PatrolGoAction.GetSSAction(patrol.transform.position); this.RunAction(patrol, go_patrol, this); } public void DestroyAllAction() // 停止所有动作 { DestroyAll(); } }最后是我们游戏的图形化界面UserGUI部分，该部分向玩家提供了大量的游戏信息：public class UserGUI : MonoBehaviour { private IUserAction action; private GUIStyle score_style = new GUIStyle(); private GUIStyle text_style = new GUIStyle(); private GUIStyle over_style = new GUIStyle(); public int game_time = 0; void Start () { action = SSDirector.GetInstance().CurrentScenceController as IUserAction; text_style.normal.textColor = new Color(0, 0, 0, 1); text_style.fontSize = 16; score_style.normal.textColor = new Color(1,0.92f,0.016f,1); score_style.fontSize = 16; over_style.fontSize = 25; StartCoroutine(GameTimer()); } void Update() { //获取方向键的偏移量 float translationX = Input.GetAxis("Horizontal"); float translationZ = Input.GetAxis("Vertical"); //移动玩家 action.MovePlayer(translationX, translationZ); } private void OnGUI() { GUI.Label(new Rect(Screen.width - 170, 10, 50, 50), "剩余任务数:", text_style); GUI.Label(new Rect(Screen.width - 80, 10, 50, 50), action.GetTaskNumber().ToString(), score_style); GUI.Label(new Rect(Screen.width - 170, 30, 50, 50), "当前得分:", text_style); GUI.Label(new Rect(Screen.width - 80, 30, 50, 50), action.GetScore().ToString(), score_style); GUI.Label(new Rect(Screen.width - 170, 50, 50, 50), "当前用时:", text_style); GUI.Label(new Rect(Screen.width - 80, 50, 50, 50), game_time.ToString(), score_style); GUI.Label(new Rect(20 ,10, 100, 100), "游戏提示", text_style); GUI.Label(new Rect(20, 30, 100, 100), "按WSAD或方向键移动", text_style); GUI.Label(new Rect(20, 50, 100, 100), "成功躲避巡逻兵追捕加1分到达一个任务点", text_style); GUI.Label(new Rect(20, 70, 100, 100), "你需要到达所有的任务点", text_style); if(action.GetGameover() && action.GetTaskNumber() != 0) { GUI.Label(new Rect(Screen.width / 2 - 100, Screen.width / 2 - 250, 100, 100), "遗憾，任务失败。", over_style); if (GUI.Button(new Rect(Screen.width / 2 - 50, Screen.width / 2 - 150, 100, 50), "再来一局")) { action.Restart(); return; } } else if(action.GetTaskNumber() == 0) { GUI.Label(new Rect(Screen.width / 2 - 100, Screen.width / 2 - 250, 100, 100), "恭喜，完成任务！", over_style); if (GUI.Button(new Rect(Screen.width / 2 - 50, Screen.width / 2 - 150, 100, 50), "再来一局")) { action.Restart(); return; } } } public IEnumerator GameTimer() { while (!action.GetGameover()) { yield return new WaitForSeconds(1); game_time++; } } }总之，在上面的实现中，我们运用了发布与订阅模式（它可以定义了一种一对多的依赖关系，实现了让多个订阅者对象同时监听某一个主题对象。当监听到变化时，订阅者即可据此做出相应的变化。）和工厂模式（批量生成了巡逻兵）。本人的“3D游戏编程与设计”系列合集，请访问：https://www.yizuodi.cn/category/3DGame/
本文参考官方手册(https://docs.unity3d.com/ScriptReference/Editor.html)，编写一个简单的自定义Component(组件)，用自定义组件定义几种飞碟，编辑并赋予飞碟一些属性，做成预制首先我们可以定义飞碟的一些共有的属性，脚本DiskData：public class DiskData : MonoBehaviour { public int score = 1; // 射击命中得分 public Color color = Color.white; // 颜色 public Vector3 direction; // 初始的位置 public Vector3 scale = new Vector3(1 ,0.25f ,1); // 大小 }我们把它绑定到飞碟上，在检查器中可以看到几项属性：继续参考Unity的文档：https://docs.unity3d.com/cn/current/ScriptReference/Editor.html可知需要我们新建一个继承Editor的脚本类MyDiskEditor，让我们有一个使用 IMGUI 和多重选择的自定义检视面板。如下：using UnityEngine; using UnityEditor; using System.Collections; [CustomEditor(typeof(DiskData))] [CanEditMultipleObjects] public class MyDEditor : Editor { SerializedProperty score; // 分数 SerializedProperty color; // 颜色 SerializedProperty scale; // 大小 void OnEnable() { // 序列化属性 score = serializedObject.FindProperty("score"); color = serializedObject.FindProperty("color"); scale = serializedObject.FindProperty("scale"); } public override void OnInspectorGUI() { serializedObject.Update(); // 更新序列化属性 EditorGUILayout.IntSlider(score, 0, 5, new GUIContent("score")); // 分数 if (!score.hasMultipleDifferentValues) { ProgressBar(score.intValue / 5f, "score"); // 进度条 } EditorGUILayout.PropertyField(color); // 颜色 EditorGUILayout.PropertyField(scale); // 大小 serializedObject.ApplyModifiedProperties(); // 应用修改 } private void ProgressBar(float value, string label) { Rect rect = GUILayoutUtility.GetRect(18, 18, "TextField"); // 进度条的位置 EditorGUI.ProgressBar(rect, value, label); // 进度条 EditorGUILayout.Space(); // 空行 } }然后我们制作5款不同的飞碟，它们的大小、颜色不同，击中后得分亦不同：下面分别是第1个和第5个飞碟的情况：本人的“3D游戏编程与设计”系列合集，请访问：https://www.yizuodi.cn/category/3DGame/
本文主要基于资源商店中获取到的Fantasy Skybox FREE资源包，进行游戏场景构建，并为场景设计地形（有树木和草地等元素）首先我们从资源商店获取到Fantasy Skybox FREE资源包：https://assetstore.unity.com/packages/2d/textures-materials/sky/fantasy-skybox-free-18353然后在我们的项目中导入资源包（在包管理器中找到它，下载并导入）。导入完成后，我们在 窗口-渲染-光照 选项中，选择环境，选择天空盒材质以应用上述资源包中的材质。然后，我们在组件中找到上述资源包的SampleTerrain并添加，让我们的场景中有地形的存在，添加成功后效果如下：Unity Editor包含的地形(Terrain)功能，可以让我们添加地形，并对地形进行一些操作（地形升降、添加花草等），文档内容可见：https://docs.unity3d.com/cn/current/Manual/script-Terrain.html根据上述描述，在Fantasy Skybox FREE内置SampleTerrain的地形基础上，我们使用其“绘制细节”，里面的细节包含了两种草和一种花，可供我们直接使用，我们为场景添加后效果如下：另外，我们可能还需要一些树，Unity为我们提供了树编辑器(Tree Editor)，用于在编辑器内直接设计树。可见文档：https://docs.unity3d.com/cn/current/Manual/class-Tree.html在 游戏对象-3D对象-树 中，我们可以创建树，依据文档的指引，我们可以有一棵如下的树：我们为它的树枝和树叶加一些材质，就形成了一棵完整的树：添加到游戏里面，效果如下：当然，我们自行制作的树可能还不够美观，如果不喜欢的话，我们还可以选用资源商店中提供的资源包。比如使用Mobile Tree Package(https://assetstore.unity.com/packages/3d/vegetation/trees/mobile-tree-package-18866)资源包中的树，效果如下：这样，我们就构建了简单的游戏场景。本人的“3D游戏编程与设计”系列合集，请访问：https://www.yizuodi.cn/category/3DGame/
本文主要涉及基于Unity3D的物体的抛物线运动实现。方法 1：直接修改对象的Transform属性using System.Collections; using System.Collections.Generic; using UnityEngine; public class Move : MonoBehaviour { void Start(){} void Update() { this.transform.position += Vector3.right * Time.deltaTime * 3; this.transform.position += Vector3.down * Time.deltaTime * 3; } }方法 2：使用向量Vector3的方法我们将对象的最新位置用Vector3向量来表示，然后将这个向量赋给对象。下面我们让对象水平方向匀速，垂直方向受重力（模拟）影响下落。using System.Collections; using System.Collections.Generic; using UnityEngine; public class Move : MonoBehaviour { float accumulateTime = 0; void Start(){} void Update() { accumulateTime += Time.deltaTime; //水平方向匀速运动 float x = 5 * accumulateTime; //垂直方向重力加速度下落运动 float y = 30 - 9.8f * 0.5f * Mathf.Pow(accumulateTime, 2); Vector3 pos = new Vector3(x, y, 0); if (pos.y >= 0) { transform.position = pos; } } }方法 3：通过Rigidbody组件移动物体Rigidbody组件可以让对象受物理引擎的控制。我们可以开启重力作用，并赋予物体一个水平速度，从而实现抛物线运动。using System.Collections; using System.Collections.Generic; using UnityEngine; [RequireComponent(typeof(Rigidbody))] public class Move : MonoBehaviour { private Rigidbody rigidbody; private Vector3 initSpeed; void Start() { rigidbody = this.GetComponent<Rigidbody> (); initSpeed = new Vector3 (3, 0, 0); rigidbody.velocity = initSpeed; } void Update(){} }方法 4：通过CharacterController组件移动物体CharacterController，即角色控制器，我们可以用其提供的Move方法来实现抛物线运动。using System.Collections; using System.Collections.Generic; using UnityEngine; [RequireComponent(typeof(CharacterController))] public class Move : MonoBehaviour { private CharacterController cc; private Vector3 speed; void Start() { cc = this.GetComponent<CharacterController> (); speed = new Vector3 (5, 0, 0); } void Update() { speed += 3 * Time.deltaTime * Vector3.down; cc.Move (speed * Time.deltaTime); } }本人的“3D游戏编程与设计”系列合集，请访问：https://www.yizuodi.cn/category/3DGame/
游戏玩法介绍井字棋，英文名叫Tic-Tac-Toe，是一种在3*3格子上进行的连珠游戏，和五子棋类似，由于棋盘一般不画边框，格线排成井字故得名。玩家需要轮流在一有九格方盘上做记号，谁先把三个同一记号排成横线、直线、斜线，谁就是获胜者。游戏演示下面的第一幅动图中，分别演示了“开始游戏”、“重新开始”游戏功能和A获胜、B获胜两种情况，还有获胜的三种方式(横、竖、对角线)。当然，我们也有平局的情况：代码展示完整的代码如下，OnGUI每帧都会被调用，从而实现我们的游戏功能。using System.Collections; using System.Collections.Generic; using UnityEngine; public class game : MonoBehaviour { private int[,] chess_state = new int[3, 3]; // 棋盘落子状态，0: empty, 1: playerA, 2: playerB private int turn = 1; // 回合，1: playerA, 2: playerB private bool start_state = false; // 用于标定游戏是否开始 private int game_state = 0; // 游戏状态0: 棋盘未满，1: playerA, 2: playerB, 3: 平局 // Start is called before the first frame update void Start() { start_state = false; newGame(); } // Update is called once per frame void Update() { } // 开始新的对局 private void newGame() { game_state = 0; turn = 1; for (int i = 0; i < 3; i++) for (int j = 0; j < 3; j++) chess_state[i, j] = 0; } // 判断是否获胜 private int getGameState() { for (int i = 0; i < 3; i++) { // 判断竖向是否有相同的棋子 if (chess_state[i, 0] != 0 && chess_state[i, 0] == chess_state[i, 1] && chess_state[i, 0] == chess_state[i, 2]) return chess_state[i, 0]; // 判断横向是否有相同的棋子 if (chess_state[0, i] != 0 && chess_state[0, i] == chess_state[1, i] && chess_state[0, i] == chess_state[2, i]) return chess_state[0, i]; } // 判断对角线是否有相同的棋子 if (chess_state[0, 0] != 0 && chess_state[0, 0] == chess_state[1, 1] && chess_state[0, 0] == chess_state[2, 2]) return chess_state[1, 1]; if (chess_state[0, 2] != 0 && chess_state[0, 2] == chess_state[1, 1] && chess_state[0, 2] == chess_state[2, 0]) return chess_state[1, 1]; // 判断棋盘是否已满，如果没有，则返回0 for (int i = 0; i < 3; i++) for (int j = 0; j < 3; j++) if (chess_state[i, j] == 0) return 0; return 3; } // OnGUI每帧都会被调用，用于绘制UI void OnGUI() { GUI.skin.button.fontSize = 20; GUI.skin.button.fontStyle = FontStyle.Bold; GUI.skin.button.normal.background = null; GUI.skin.button.hover.background = null; GUI.skin.button.active.background = null; GUI.skin.label.fontSize = 20; GUI.skin.label.normal.textColor = Color.red; if (!start_state) { // 开始游戏按钮 if (GUI.Button(new Rect(Screen.width *1 / 5, Screen.height * 2 / 5, 200, 50), "新的对局")) start_state = true; // 游戏标题 GUI.skin.label.fontSize = 32; GUI.Label(new Rect(Screen.width * 1 / 5, Screen.height * 1 / 5, 200, 100), "井字棋1.0"); GUI.Label(new Rect(Screen.width * 3 / 5, Screen.height * 3 / 10, 200, 100), "轻触按钮\n开始游戏"); } else { // 重新开始按钮 if (GUI.Button(new Rect(Screen.width * 1 / 5, Screen.height * 2 / 5, 200, 50), "重新开始")) newGame(); // 根据当前回合情况决定，game_state为0棋盘未满，1玩家A获胜，2玩家B获胜，3棋盘已满，平局 switch (game_state) { case 1: GUI.Label(new Rect(Screen.width * 1 / 5, Screen.height * 1 / 5, 200, 50), "恭喜玩家A获胜！"); break; case 2: GUI.Label(new Rect(Screen.width * 1 / 5, Screen.height * 1 / 5, 200, 50), "恭喜玩家B获胜！"); break; case 3: GUI.Label(new Rect(Screen.width * 1 / 5, Screen.height * 1 / 5, 200, 50), "无人获胜，平局"); break; default: GUI.Label(new Rect(Screen.width * 1 / 5, Screen.height * 1 / 5, 200, 50), "当前回合：" + (turn == 1 ? "玩家A" : "玩家B")); break; } // 绘制棋盘 for (int i = 0; i < 3; i++) { for (int j = 0; j < 3; j++) { if (GUI.Button(new Rect(Screen.width * 1 / 2 + 60 * i, Screen.height * 1 / 5 + 60 * j, 60, 60), chess_state[i, j] == 0 ? "" : chess_state[i, j] == 1 ? "A" : "B")) { // 如果被按下，且当前回合未结束，且当前位置未落子，则落子 if (chess_state[i, j] == 0 && game_state == 0) { chess_state[i, j] = turn; game_state = getGameState(); turn = turn == 1 ? 2 : 1; } } } } } } }游戏源代码获取获取源代码，除了复制上面的源代码，您还可以访问https://github.com/yizuodi/3dgame/tree/main/Tic-Tac-Toe仅提供了C#编写的脚本文件，可自行导入项目中，装载后使用。参考资料Unity中Rect类型详解（CSDN, w199753）本人的“3D游戏编程与设计”系列合集，请访问：https://www.yizuodi.cn/category/3DGame/
2022年9月上旬中国大陆游戏分类与热点分析
游戏分类简要描述首先我们需要了解，对于游戏的分类，我们没有严格的分类方法——游戏世界本就是丰富多彩的，我们可以依照游戏玩法、游戏平台、游戏内容、盈利模式、发行地区等等因素来进行分类。我绘制了如下的思维导图来进行展现（思维导图由幕布生成。）。点我在“幕布”中访问该思维导图手机游戏市场下载量/排名信息分析我们下面选取了iOS App Store、小米应用商店、魅族应用商店、华为应用商店、OPPO应用商店、豌豆荚、应用宝几大手机应用商店的游戏分类榜单和TapTap（心动网络旗下游戏社区）数据。数据均选取“中国区”数据（因此Google play数据不纳入分析范围）。统计时间：2022年9月11日20:00（下方仅作数据展示，查看分析请直接跳到本文末尾）App Store数据小米应用商店魅族应用商店华为应用商店OPPO应用商店豌豆荚应用宝TapTap热玩榜根据上面的榜单数据，简单统计得到部分游戏在上述榜单的出现次数：（上述分类参考游戏市场给予游戏的标签，由于游戏实际玩法多样，不能以单一类型定性，因而上述分类仅能用作参考）由此我们知晓，当前游戏市场的热点——1.主要市场由头部厂商占据。统计数据中，腾讯共上榜游戏12款，网易上榜4款，米哈游上榜2款，上榜的其他厂商均只有一款游戏上榜。2.动作游戏较为热门。包括像“和平精英”类型的“吃鸡”游戏，“王者荣耀”、“英雄联盟手游”为代表的moba游戏。它们用各种方式消灭敌人或保存自己以过关，不刻意追求故事情节，符合当代人通过“无脑”游戏来释放解压的短平快的需求。3.一些休闲益智游戏经久不衰。比如“贪吃蛇大作战”是一款2016年6月8日上线的竞技性质的休闲游戏，6年过去仍然上榜两次。带有策略属性的“开心消消乐”，2013年8月，正式在网页版上线，2014年2月推出iOS版本，2014年4月推出Android版本，至今仍能上榜4次。桌面类的“欢乐斗地主”从桌面，到PC、再到手机，也是经久不衰。这表现出玩家对于休闲益智游戏持续的需求，也正说明休闲益智游戏满足了玩家的需要。4.一些其他平台移植的游戏仍有热度。例如榜单上的“QQ飞车手游”、“使命召唤手游”。5.“二次元”类型游戏较为热门。以此类主题游戏起家的米哈游在上述统计中上榜两次，“明日方舟”在榜单上亦有较好表现。6.休闲的沙盒类游戏颇受欢迎。由Mojang开发、网易代理的“我的世界”上榜四次，迷你玩科技的类似产品“迷你世界”也上榜五次。