坦克大战demo

落小泪3天前作品集36

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进行搭建,添加按钮监听事件,更改值的同时保存数据,再次打开游戏时将读取音量值。

  • 数据持久化:

    • 基于反射来支持各种数据类型,包括基本类型、ListDictionary,甚至是嵌套的自定义类,非常强大且通用。

    • 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);
              }
      
          }
      }
  • 场景构建

    • 使用导入的预制体模型构建场景

    • 添加简易障碍物与掩体,增加游戏性与可玩性


三、开发过程与技术重点

在项目开发过程中,我不仅跟随教学实现了基础功能,同时在多个关键模块上做了深入学习与扩展:

  1. 角色控制逻辑优化
    初始实现中坦克转向不够流畅,我研究并引入 Quaternion.Lerp 插值与 Transform.forward 校正,提升了移动手感。

  2. 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;
    }
  3. 射击系统封装
    将炮弹发射逻辑封装为模块化脚本,支持多种发射模式(单发 / 齐发),并通过标签区分敌我单位。

  4. 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);
        }
    }
  5. 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 更新 → 游戏胜负判断

开发过程中,我严格遵循模块化、组件化的开发思想,脚本命名规范清晰,结构明了,方便维护与拓展。

image.png


五、项目价值与收获

通过本项目的开发,我在以下方面有了显著提升:

  • Unity 编辑器操作熟练度:掌握场景搭建、组件管理、物理系统使用等常用流程;

  • C# 编程能力:在面向对象设计、事件系统、封装优化等方面有了实践经验;

  • 调试与问题解决能力:多次独立调试错误,利用控制台输出与分步验证定位 Bug;

  • 游戏逻辑思维建立:逐步建立起从“功能”到“系统”的逻辑设计框架;

  • 自我驱动学习能力:在遇到超出教程的技术难点时,主动查阅官方文档和社区资源进行深入理解。


六、后续计划

在此项目的基础上,我计划进一步扩展功能,包括:

  • 添加爆炸粒子特效与音效反馈,提升视觉与听觉表现;

  • 引入 NavMesh 实现更复杂的敌人寻路与 AI 行为;

  • 尝试拓展为局域网联机对战版本,探索 Unity 的网络模块(Mirror / Netcode for GameObjects);


返回列表

没有更早的文章了...

没有最新的文章了...

发表评论    

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。