坦克大战demo
Unity 3D 项目实战:《坦克大战》Demo 实现总结
在线体验:https://www.luoxiaolei.space/TankWeb/index.html
项目名称:坦克大战(Tank Battle)
开发平台:Unity 2022.3 LTS
脚本语言:C#
项目时长:约 2 天
项目性质:课程项目实战,独立完成
一、项目概述
《坦克大战》是我在学习 Unity 引擎过程中所完成的一个完整 Demo 项目,旨在通过实现一个小型 3D 战斗场景,掌握游戏开发中常用的输入控制、物理系统、AI 行为、UI 系统以及基本战斗逻辑等模块。该项目参考了唐老师的入门教学视频,但在此基础上我加入了部分优化与自主扩展,以更贴近真实开发需求。
二、项目功能结构
本项目围绕“玩家控制坦克与敌方坦克对战”为核心玩法,设计并实现了如下功能:
玩家坦克控制:
使用键盘
WASD
实现前后移动与旋转鼠标控制炮塔方向并发射炮弹
使用 Rigidbody 与 Collider 实现物理碰撞与移动响应
敌方 AI 行为:
自动巡逻并检测玩家位置
发射炮弹攻击玩家
具有简单的生命值系统,受击后掉血并被销毁
战斗系统:
使用射线检测判断命中与否
子弹实例化、飞行、碰撞、销毁完整生命周期管理
通过对象池管理子弹资源,提升性能
UI 系统:
使用GUI进行显示,主要为了练习GUI,后续将会添加NGUI和FGUI版本
显示玩家血量(血条 UI 动态更新),敌人血条也根据与玩家的位置距离进行缩放
游戏胜负逻辑判断(胜利 / 失败提示)
音频系统:
使用GUI进行搭建,添加按钮监听事件,更改值的同时保存数据,再次打开游戏时将读取音量值。
数据持久化:
基于反射来支持各种数据类型,包括基本类型、
List
、Dictionary
,甚至是嵌套的自定义类,非常强大且通用。using System; using System.Collections; using System.Reflection; using UnityEngine; /// <summary> /// PlayerPrefs数据管理类 统一管理数据的存储和读取 /// </summary> public class PlayerPrefsDataMgr { private static PlayerPrefsDataMgr instance = new PlayerPrefsDataMgr(); public static PlayerPrefsDataMgr Instance { get { return instance; } } private PlayerPrefsDataMgr() { } /// <summary> /// 存储数据 /// </summary> /// <param name="data">数据对象</param> /// <param name="keyName">数据对象的唯一key 自己控制</param> public void SaveData(object data, string keyName) { //就是要通过 Type 得到传入数据对象的所有的 字段 //然后结合 PlayerPrefs来进行存储 #region 第一步 获取传入数据对象的所有字段 Type dataType = data.GetType(); //得到所有的字段 FieldInfo[] infos = dataType.GetFields(); #endregion #region 第二步 自己定义一个key的规则 进行数据存储 //我们存储都是通过PlayerPrefs来进行存储的 //保证key的唯一性 我们就需要自己定一个key的规则 //我们自己定一个规则 // keyName_数据类型_字段类型_字段名 #endregion #region 第三步 遍历这些字段 进行数据存储 string saveKeyName = ""; FieldInfo info; for (int i = 0; i < infos.Length; i++) { //对每一个字段 进行数据存储 //得到具体的字段信息 info = infos[i]; //通过FieldInfo可以直接获取到 字段的类型 和字段的名字 //字段的类型 info.FieldType.Name //字段的名字 info.Name; //要根据我们定的key的拼接规则 来进行key的生成 //Player1_PlayerInfo_Int32_age saveKeyName = keyName + "_" + dataType.Name + "_" + info.FieldType.Name + "_" + info.Name; //现在得到了Key 按照我们的规则 //接下来就要来通过PlayerPrefs来进行存储 //如何获取值 //info.GetValue(data) //封装了一个方法 专门来存储值 SaveValue(info.GetValue(data), saveKeyName); } PlayerPrefs.Save(); #endregion } private void SaveValue(object value, string keyName) { //直接通过PlayerPrefs来进行存储了 //就是根据数据类型的不同 来决定使用哪一个API来进行存储 //PlayerPrefs只支持3种类型存储 //判断 数据类型 是什么类型 然后调用具体的方法来存储 Type fieldType = value.GetType(); //类型判断 //是不是int if (fieldType == typeof(int)) { //为int数据加密 int rValue = (int)value; rValue += 10; PlayerPrefs.SetInt(keyName, rValue); } else if (fieldType == typeof(float)) { PlayerPrefs.SetFloat(keyName, (float)value); } else if (fieldType == typeof(string)) { PlayerPrefs.SetString(keyName, value.ToString()); } else if (fieldType == typeof(bool)) { //自己顶一个存储bool的规则 PlayerPrefs.SetInt(keyName, (bool)value ? 1 : 0); } //如何判断 泛型类的类型呢 //通过反射 判断 父子关系 //这相当于是判断 字段是不是IList的子类 else if (typeof(IList).IsAssignableFrom(fieldType)) { //父类装子类 IList list = value as IList; //先存储 数量 PlayerPrefs.SetInt(keyName, list.Count); int index = 0; foreach (object obj in list) { //存储具体的值 SaveValue(obj, keyName + index); ++index; } } //判断是不是Dictionary类型 通过Dictionary的父类来判断 else if (typeof(IDictionary).IsAssignableFrom(fieldType)) { //父类装自来 IDictionary dic = value as IDictionary; //先存字典长度 PlayerPrefs.SetInt(keyName, dic.Count); //遍历存储Dic里面的具体值 //用于区分 表示的 区分 key int index = 0; foreach (object key in dic.Keys) { SaveValue(key, keyName + "_key_" + index); SaveValue(dic[key], keyName + "_value_" + index); ++index; } } //基础数据类型都不是 那么可能就是自定义类型 else { SaveData(value, keyName); } } /// <summary> /// 读取数据 /// </summary> /// <param name="type">想要读取数据的 数据类型Type</param> /// <param name="keyName">数据对象的唯一key 自己控制</param> /// <returns></returns> public object LoadData(Type type, string keyName) { //根据传入的Type 创建一个对象 用于存储数据 object data = Activator.CreateInstance(type); //要往这个new出来的对象中存储数据 填充数据 //得到所有字段 FieldInfo[] infos = type.GetFields(); //用于拼接key的字符串 string loadKeyName = ""; //用于存储 单个字段信息的 对象 FieldInfo info; for (int i = 0; i < infos.Length; i++) { info = infos[i]; //key的拼接规则 一定是和存储时一模一样 这样才能找到对应数据 loadKeyName = keyName + "_" + type.Name + "_" + info.FieldType.Name + "_" + info.Name; //有key 就可以结合 PlayerPrefs来读取数据 //填充数据到data中 info.SetValue(data, LoadValue(info.FieldType, loadKeyName)); } return data; } /// <summary> /// 得到单个数据的方法 /// </summary> /// <param name="fieldType">字段类型 用于判断 用哪个api来读取</param> /// <param name="keyName">用于获取具体数据</param> /// <returns></returns> private object LoadValue(Type fieldType, string keyName) { //根据 字段类型 来判断 用哪个API来读取 if (fieldType == typeof(int)) { //解密 减10 return PlayerPrefs.GetInt(keyName, 0) - 10; } else if (fieldType == typeof(float)) { return PlayerPrefs.GetFloat(keyName, 0); } else if (fieldType == typeof(string)) { return PlayerPrefs.GetString(keyName, ""); } else if (fieldType == typeof(bool)) { //根据自定义存储bool的规则 来进行值的获取 return PlayerPrefs.GetInt(keyName, 0) == 1 ? true : false; } else if (typeof(IList).IsAssignableFrom(fieldType)) { //得到长度 int count = PlayerPrefs.GetInt(keyName, 0); //实例化一个List对象 来进行赋值 //用了反射中双A中 Activator进行快速实例化List对象 IList list = Activator.CreateInstance(fieldType) as IList; for (int i = 0; i < count; i++) { //目的是要得到 List中泛型的类型 list.Add(LoadValue(fieldType.GetGenericArguments()[0], keyName + i)); } return list; } else if (typeof(IDictionary).IsAssignableFrom(fieldType)) { //得到字典的长度 int count = PlayerPrefs.GetInt(keyName, 0); //实例化一个字典对象 用父类装子类 IDictionary dic = Activator.CreateInstance(fieldType) as IDictionary; Type[] kvType = fieldType.GetGenericArguments(); for (int i = 0; i < count; i++) { dic.Add(LoadValue(kvType[0], keyName + "_key_" + i), LoadValue(kvType[1], keyName + "_value_" + i)); } return dic; } else { return LoadData(fieldType, keyName); } } }
场景构建:
使用导入的预制体模型构建场景
添加简易障碍物与掩体,增加游戏性与可玩性
三、开发过程与技术重点
在项目开发过程中,我不仅跟随教学实现了基础功能,同时在多个关键模块上做了深入学习与扩展:
角色控制逻辑优化:
初始实现中坦克转向不够流畅,我研究并引入Quaternion.Lerp
插值与Transform.forward
校正,提升了移动手感。private void HandleMovement() { float vertical = Input.GetAxis("Vertical"); float horizontal = Input.GetAxis("Horizontal"); Vector3 moveDir = vertical * Vector3.forward; transform.Translate(moveDir * moveSpeed * Time.deltaTime, Space.Self); float targetYRotation = horizontal * rotateSpeed * Time.deltaTime; transform.Rotate(Vector3.up, targetYRotation, Space.Self); } private void HandleTurretRotation() { // 使用 Raw 输入更稳定 float mouseX = Input.GetAxisRaw("Mouse X"); // 根据输入计算目标旋转角度(限制极端变化) float deltaRotation = Mathf.Clamp(mouseX, -1f, 1f) * maxHeadRotateSpeed * Time.deltaTime; // 应用局部旋转 tankHead.Rotate(Vector3.up, deltaRotation, Space.Self); // 记录当前角度(如需扩展平滑逻辑用) currentHeadAngleY = tankHead.localEulerAngles.y; }
射击系统封装:
将炮弹发射逻辑封装为模块化脚本,支持多种发射模式(单发 / 齐发),并通过标签区分敌我单位。private void OnTriggerEnter(Collider other) { // 敌人子弹不能打到敌人、子弹、武器 if (fatherObj.tag == "Enemy") { if (other.tag == "Enemy" || other.tag == "Bullet" || other.tag == "Weapon" || other.tag == "Collectible") { return; } TankBaseObj tankBase = other.GetComponent<TankBaseObj>(); if(tankBase!=null) { tankBase.Wound(fatherObj); } GameObject bommeff = Instantiate(boomEff, this.transform.position, this.transform.rotation); bommeff.GetComponent<AudioSource>().volume = GameDataManager.Instance.musicData.soundValue; Destroy(this.gameObject); } // 玩家子弹不能打到玩家、子弹、武器 else if (fatherObj.tag == "Player") { if (other.tag == "Player" || other.tag == "Bullet" || other.tag == "Weapon" || other.tag == "Collectible") { return; } TankBaseObj tankBase = other.GetComponent<TankBaseObj>(); if (tankBase != null) { tankBase.Wound(fatherObj); } GameObject bommeff = Instantiate(boomEff, this.transform.position, this.transform.rotation); bommeff.GetComponent<AudioSource>().volume = GameDataManager.Instance.musicData.soundValue; Destroy(this.gameObject); } }
AI 逻辑实现:
敌方 AI 使用坐标进行简易巡逻,后期将更改为NavMeshAgent
进行导航,并结合距离判断与射线检测判断是否发起攻击,模拟基础行为逻辑。
using UnityEngine; public class MonsterObj : TankBaseObj { private Transform targetPos; public float fireTime; private float nowtime; public GameObject bullet; public Transform[] shootPos; public Transform[] randomPos; public Transform player; public Texture maxHpBK; public Texture hpBK; private float showTime; private Rect maxHpRect; private Rect hpRect; // Start is called before the first frame update void Start() { if (randomPos != null) RandomPos(); } // Update is called once per frame void Update() { nowtime += Time.deltaTime; if (targetPos != null) { var targetDir = targetPos.position - this.transform.position; var targetRotation = Quaternion.LookRotation(targetDir, Vector3.up); this.transform.rotation = Quaternion.RotateTowards( this.transform.rotation, targetRotation, rotateSpeed * Time.deltaTime ); this.transform.Translate(Vector3.forward * moveSpeed * Time.deltaTime); if (Vector3.Distance(this.transform.position, targetPos.position) < 0.05f) { RandomPos(); } } if (Vector3.Distance(this.transform.position, player.position) < 10f) { // 获取目标方向 Vector3 dir = player.position - tankHead.position; Quaternion targetRot = Quaternion.LookRotation(dir, Vector3.up); // 平滑旋转 tankHead.rotation = Quaternion.RotateTowards( tankHead.rotation, targetRot, headeRotateSpeed * Time.deltaTime ); if (nowtime >= fireTime) { Fire(); nowtime = 0; } } } private void RandomPos() { if (randomPos.Length != 0) { targetPos = randomPos[Random.Range(0, randomPos.Length)]; } } public override void Fire() { for (int i = 0; i < shootPos.Length; i++) { GameObject obj = Instantiate(bullet, shootPos[i].position, shootPos[i].rotation); obj.GetComponent<BulletObj>().SetFatherObj(this); } } public override void Dead() { base.Dead(); GamePanel.Instance.UpdateScore(1); } public override void Wound(TankBaseObj tank) { base.Wound(tank); showTime = 3; } private void OnGUI() { if (showTime > 0) { showTime -= Time.deltaTime; Vector3 screenPos = Camera.main.WorldToScreenPoint(this.transform.position); screenPos.y = Screen.height - screenPos.y; // 计算距离,近大远小 float distance = Vector3.Distance(Camera.main.transform.position, this.transform.position); float scale = Mathf.Clamp(1f / distance * 20f, 0.5f, 2.0f); // 距离越近scale越大,限制缩放范围 float barWidth = 120 * scale; float barHeight = 17 * scale; float hpWidth = (float)hp / maxHp * 100f * scale; maxHpRect.x = screenPos.x - barWidth / 2; maxHpRect.y = screenPos.y - 100 * scale; maxHpRect.width = barWidth; maxHpRect.height = barHeight; GUI.DrawTexture(maxHpRect, maxHpBK); hpRect.x = screenPos.x - barWidth / 2; hpRect.y = screenPos.y - 100 * scale; hpRect.width = hpWidth; hpRect.height = barHeight; GUI.DrawTexture(hpRect, hpBK); } } }
四、项目成果展示
该 Demo 目前已实现完整的游戏闭环,包含:
玩家输入 → 角色响应
子弹逻辑 → 命中判定 → 血量计算
敌人反应 → 攻击决策
UI 更新 → 游戏胜负判断
开发过程中,我严格遵循模块化、组件化的开发思想,脚本命名规范清晰,结构明了,方便维护与拓展。
五、项目价值与收获
通过本项目的开发,我在以下方面有了显著提升:
Unity 编辑器操作熟练度:掌握场景搭建、组件管理、物理系统使用等常用流程;
C# 编程能力:在面向对象设计、事件系统、封装优化等方面有了实践经验;
调试与问题解决能力:多次独立调试错误,利用控制台输出与分步验证定位 Bug;
游戏逻辑思维建立:逐步建立起从“功能”到“系统”的逻辑设计框架;
自我驱动学习能力:在遇到超出教程的技术难点时,主动查阅官方文档和社区资源进行深入理解。
六、后续计划
在此项目的基础上,我计划进一步扩展功能,包括:
添加爆炸粒子特效与音效反馈,提升视觉与听觉表现;
引入 NavMesh 实现更复杂的敌人寻路与 AI 行为;
尝试拓展为局域网联机对战版本,探索 Unity 的网络模块(Mirror / Netcode for GameObjects);