Unity 2D射击游戏模板项目实战——2D Wave Shooter完整开发指南

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:Unity 2D射击游戏模板“2D Wave Shooter 1”是一个面向初学者与进阶开发者的完整游戏框架,涵盖从基础操作到核心机制的全面实现。该模板基于Unity引擎和C#脚本语言,提供2D游戏开发的核心组件与逻辑结构,包括角色控制、射击机制、敌人波浪系统、UI界面、音频管理及物理系统等。通过本项目实战,开发者可快速掌握Unity 2D开发流程,理解游戏对象与组件协作机制,并具备独立构建类似射击类游戏的能力。
unity2D射击游戏模板2D Wave Shooter 1.zip

1. Unity引擎基础与2D开发环境搭建

Unity编辑器核心界面概览

Unity编辑器采用模块化布局,主要由Scene视图、Game视图、Hierarchy、Inspector与Project面板构成。Scene视图用于场景构建与对象摆放,支持2D/3D模式切换;Game视图实时预览运行效果,可模拟不同分辨率表现;Hierarchy显示当前场景对象层级结构;Inspector展示选中对象的组件属性与参数配置;Project面板管理项目资源文件,如脚本、纹理、预制体等。

创建2D项目与关键设置

新建项目时选择“2D”模板,Unity将自动启用2D渲染管线并配置默认坐标系为Y-up。需调整 Time > Fixed Timestep 至0.02(即50Hz)以匹配物理更新频率,并在 Sprite Import Settings 中统一设置 Pixels Per Unit = 16 (常见于像素艺术),确保图像缩放一致性。同时勾选 Asset Serialization > Force Text 便于版本控制对比。

导入模板项目与开发前准备

解压“2D Wave Shooter 1.zip”后,通过Unity Hub导入项目文件夹,系统会自动检测Unity版本兼容性(建议使用2022.3 LTS)。若提示缺少 Unity Physics 2D Module ,需在Package Manager中安装对应组件。开发前应规划标准目录结构(如 Assets/Scripts , Prefabs , Scenes ),并在 Script Execution Order 中设定关键脚本加载优先级。最后初始化Git仓库,添加 .gitignore (推荐使用 github/gitignore ),实现代码版本追踪。

2. 2D游戏对象创建与管理(Sprites、Physics 2D、Lighting 2D)

在Unity中开发2D射击类游戏,核心任务之一是高效地创建和管理各种2D游戏对象。这些对象不仅包括视觉呈现的精灵图像(Sprite),还涉及物理交互系统、光照渲染以及场景组织结构。本章将深入探讨如何通过Sprite资源优化、Physics 2D系统的精准配置、Lighting 2D的动态增强以及Prefab化管理策略,构建一个既高性能又易于维护的游戏架构。

2.1 Sprite资源的导入与优化配置

Sprite作为2D游戏中最基本的视觉元素,其质量与处理方式直接影响游戏性能与画面表现力。从一张PNG图片到可在场景中使用的GameObject,Unity提供了完整的导入流程和多种优化选项。理解并合理使用这些功能,是打造专业级2D项目的前提。

2.1.1 图像格式选择与Sprite Mode设置(Single vs Multiple)

选择合适的图像格式是资源优化的第一步。对于2D游戏而言, PNG 是最常用的选择,因其支持透明通道且无损压缩,适合图标、角色帧动画等需要高质量显示的内容;而 JPG 虽然体积更小,但不支持Alpha通道,仅适用于背景图等不需要透明度的静态素材;若追求极致加载速度和运行效率,可考虑使用 ASTC 或 ETC2 等GPU纹理压缩格式,尤其是在移动端项目中。

当导入图像后,在Inspector面板中需将其Texture Type设为“Sprite (2D and UI)”,然后根据用途设置 Sprite Mode

模式 说明 使用场景
Single 整张图作为一个独立精灵 角色站立帧、UI按钮
Multiple 一张图集包含多个子精灵 动画序列、瓦片地图
Polygon 自定义多边形网格裁剪 不规则形状精灵,节省绘制顶点
// 示例代码:动态获取Sprite中的子精灵(适用于Multiple模式)
using UnityEngine;
using System.Collections.Generic;

public class SpriteExtractor : MonoBehaviour
{
    public SpriteAtlas spriteAtlas; // 可选替代方案
    public Texture2D spriteSheet;
    void Start()
    {
        Sprite[] sprites = Resources.LoadAll<Sprite>("PlayerAnimations");
        foreach (Sprite s in sprites)
        {
            Debug.Log($"Loaded sprite: {s.name}, Size: {s.rect.size}");
        }
    }
}

逻辑分析
- Resources.LoadAll<Sprite> 用于从Resources文件夹加载所有Sprite类型资源。
- 此方法适用于小型项目或原型阶段,但在大型项目中应避免使用Resources路径以减少内存占用。
- s.rect.size 提供了该Sprite在原始图集中的像素尺寸,可用于计算动画播放比例或碰撞体大小。

此机制常用于帧动画系统初始化时提取每一帧图像。

graph TD
    A[导入PNG/JPG] --> B{是否为图集?}
    B -- 是 --> C[设置Sprite Mode为Multiple]
    B -- 否 --> D[设置为Single]
    C --> E[点击Sprite Editor进行切分]
    D --> F[直接拖入场景生成SpriteRenderer]
    E --> G[保存并应用]
    G --> H[生成多个子Sprite资产]

该流程图清晰展示了从原始图像到可用Sprite的完整转化路径,强调了Sprite Mode的关键作用。

2.1.2 精灵网格裁剪与Pivot点调整技巧

默认情况下,Unity会为每个Sprite生成矩形网格(Mesh),即使图像内容仅为中间一小部分,其余透明区域仍会被渲染。这不仅浪费GPU填充率,也可能影响碰撞检测精度。因此,使用 Custom Mesh Tight Mode 进行网格裁剪至关重要。

进入 Sprite Editor 后,点击“Trim”可以自动去除空白边缘;启用“Polygon Collider 2D”时,“Generate Geometry”功能会基于Alpha阈值生成精确轮廓,进一步提升物理模拟准确性。

更重要的是 Pivot点 的设置——它决定了旋转、缩放和变换操作的中心位置。例如:

  • 射击游戏中子弹的Pivot通常设在底部中央,以便发射时从枪口对齐;
  • 角色行走动画的Pivot建议设在脚底,便于地面贴合判断;
  • 旋转武器如链锯,Pivot应位于握柄处。

可通过以下方式编程控制Pivot偏移:

using UnityEngine;

public class PivotAdjuster : MonoBehaviour
{
    public Vector2 customPivotOffset = new Vector2(0, -0.5f); // 下移半单位

    void UpdatePivot()
    {
        SpriteRenderer sr = GetComponent<SpriteRenderer>();
        if (sr != null && sr.sprite != null)
        {
            Transform t = transform;
            Vector2 pivotInWorld = (Vector2)t.position - 
                (sr.sprite.pivot / sr.sprite.pixelsPerUnit - customPivotOffset);
            t.position = pivotInWorld;
        }
    }
}

参数说明
- sr.sprite.pivot :返回原始图像中Pivot的像素坐标。
- pixelsPerUnit :决定多少像素对应一个世界单位,影响坐标换算。
- customPivotOffset :允许开发者微调逻辑中心位置,适应不同动画需求。

此技术特别适用于需要频繁更换Sprite但仍保持一致行为逻辑的对象,如换装系统或技能特效。

此外,Unity 2021以后版本支持在Sprite Editor中直接编辑Pivot,并可保存为预制模板,极大提升了工作流效率。

2.1.3 使用Sprite Atlas提升渲染效率

在复杂2D场景中,频繁切换材质会导致大量Draw Call,严重拖慢帧率。 Sprite Atlas 是解决这一问题的核心工具。它能将多个Sprite打包进同一张大纹理中,使得它们共享材质,从而实现批次合并(Batching)。

创建Sprite Atlas步骤如下:
  1. 右键Project窗口 → Create → Sprite Atlas
  2. 将所需Sprite拖入“Objects for Packing”列表
  3. 设置Packing Tag(用于分组打包)
  4. 配置Max Size、Compression Format、Include in Build等选项
  5. 构建时自动打包生成图集纹理
using UnityEngine.U2D; // 必须引用U2D命名空间

public class AtlasUser : MonoBehaviour
{
    public string atlasName = "GameplayAtlas";
    private SpriteAtlas atlas;

    void Start()
    {
        atlas = Resources.Load<SpriteAtlas>(atlasName);
        if (atlas != null)
        {
            Sprite playerSprite = atlas.GetSprite("Player_Idle_01");
            GetComponent<SpriteRenderer>().sprite = playerSprite;
        }
        else
        {
            Debug.LogError("Atlas not found!");
        }
    }
}

执行逻辑说明
- SpriteAtlas.GetSprite(name) 根据名称查找已打包的子Sprite。
- 所有被包含在Atlas中的Sprite不再单独占用材质实例,极大降低渲染开销。
- 推荐按功能划分Atlas,如“UI_Atlas”、“Enemy_Atlas”、“Bullet_Atlas”,避免单个图集过大导致显存压力。

优势 描述
减少Draw Call 多个Sprite共用一张纹理,提升GPU效率
控制加载粒度 可按需异步加载特定图集
支持Mipmap与压缩 适配不同设备性能等级

结合Addressables系统,还能实现按场景动态加载/卸载Atlas,显著改善内存峰值。

pie
    title Draw Call对比(无图集 vs 使用图集)
    “无图集(独立材质)” : 48
    “使用Sprite Atlas” : 6

上述饼图示意了使用图集前后Draw Call数量的巨大差异,尤其在敌人密集出现的波浪射击场景中效果尤为明显。

综上所述,Sprite资源的科学管理不仅是美术层面的问题,更是性能优化的关键环节。通过合理选择格式、精细裁剪网格、灵活设置Pivot,并充分利用Sprite Atlas技术,开发者能够在保证视觉品质的同时,构建出流畅稳定的2D游戏体验。

3. 游戏对象与核心组件应用(Transform、Rigidbody2D、Collider2D)

在Unity 2D射击游戏开发中, Transform、Rigidbody2D 和 Collider2D 是构成所有可交互实体的基础三元组。它们分别承担着空间定位、物理行为模拟和碰撞检测的职责。理解这三大组件之间的协同机制,是构建稳定、响应灵敏且表现真实的2D游戏逻辑的关键。本章将深入剖析这三个核心组件的技术实现细节,并结合实际开发场景——如玩家移动、子弹发射、敌人追踪等——展示其在真实项目中的集成方式与优化策略。

3.1 Transform组件的空间控制机制

Transform 组件是每一个GameObject不可或缺的核心部分,它定义了物体在三维空间中的位置、旋转和缩放。尽管我们处于2D开发环境,Z轴通常被锁定或忽略,但 Transform 依然承担着极其重要的坐标系统管理功能。尤其在涉及父子层级、局部变换与世界坐标转换时,精确掌握其数学本质至关重要。

3.1.1 位置、旋转与缩放在2D空间中的数学表达

在2D游戏中,虽然视觉呈现为平面,Unity仍使用三维向量进行内部计算。因此, Transform.position 返回的是 Vector3 类型,其中 Z 分量常设为0。例如:

// 获取玩家当前位置(X, Y)
Vector2 playerPos = transform.position.xy;

// 设置新位置(保持Z=0)
transform.position = new Vector3(targetX, targetY, 0f);

参数说明
- xy 是 Unity 提供的 Vector3 扩展属性,用于提取 X 和 Y 分量组成 Vector2
- 在2D中,手动设置 Z 值为0可避免因浮点误差导致的渲染错层问题。

旋转操作在2D中主要围绕 Z 轴展开(即绕屏幕垂直方向),以度数表示方向。例如,让角色朝向鼠标指针:

Vector3 mousePos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
Vector2 direction = mousePos - transform.position;
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;

// 只绕Z轴旋转
transform.rotation = Quaternion.Euler(0f, 0f, angle);

逻辑分析
- 使用 Mathf.Atan2(y, x) 计算从原点指向目标的角度(弧度)。
- * Mathf.Rad2Deg 将弧度转为角度。
- Quaternion.Euler() 构造一个欧拉角旋转,仅影响 Z 轴。

缩放不影响2D碰撞体的实际尺寸,但会影响SpriteRenderer的显示大小。若需动态调整角色体型(如变大技能),应同步调整Collider2D的大小或使用CompositeCollider2D自动适配。

属性 用途 注意事项
position 定义物体在世界中的坐标 需确保Z=0防止层级混乱
rotation 控制朝向(Z轴为主) 使用 Euler(0,0,angle) 精确控制
localScale 缩放显示大小 不自动影响碰撞体
graph TD
    A[Transform] --> B(Position: Vector3)
    A --> C(Rotation: Quaternion)
    A --> D(Local Scale: Vector3)
    B --> E[World Space Coordinate]
    C --> F[Z-Axis Rotation for 2D Aim]
    D --> G[Visual Size Change]

该流程图展示了 Transform 各属性的功能分支及其在2D开发中的典型应用场景。

3.1.2 局部坐标与世界坐标的转换方法(TransformPoint、TransformDirection)

当处理子对象(如枪口偏移点)或射线投射时,必须区分 局部坐标 (相对于父物体)与 世界坐标 (全局坐标系)。Unity 提供了两个关键方法: TransformPoint TransformDirection

// 假设枪口是一个空子对象,位于角色右上方
public Transform gunMuzzle;

void FireBullet()
{
    // 局部偏移点(相对于角色)
    Vector3 localOffset = new Vector3(0.8f, 0.5f, 0f);

    // 转换到世界坐标
    Vector3 worldSpawnPos = transform.TransformPoint(localOffset);

    // 实例化子弹
    Instantiate(bulletPrefab, worldSpawnPos, transform.rotation);
}

逐行解读
- localOffset 表示在角色自身坐标系下的偏移(向右0.8单位,向上0.5单位)。
- TransformPoint() 自动考虑父对象的位置、旋转和缩放,输出正确的世界坐标。
- 若直接使用 transform.position + localOffset ,会忽略旋转带来的方向变化,造成偏差。

相反地, TransformDirection() 用于方向向量的转换,不包含位移信息:

Vector3 localForward = Vector2.right; // 子对象本地“前方”
Vector3 worldForward = transform.TransformDirection(localForward);
Debug.DrawRay(worldSpawnPos, worldForward * 5, Color.red);

此代码可用于绘制子弹初始飞行方向的调试射线。

方法 输入类型 是否包含位移 典型用途
TransformPoint 点坐标 ✅ 包含 子弹生成位置
TransformDirection 方向向量 ❌ 不包含 射线方向、速度设定
InverseTransformPoint 世界点 ✅ 反向转换 UI跟随判断
InverseTransformDirection 世界方向 ❌ 反向方向 AI感知区域

3.1.3 子对象层级关系在枪口偏移计算中的应用

在射击游戏中,精准的枪口位置决定了弹道起点的真实性。通过创建一个名为 GunMuzzle 的空子对象并挂载至玩家身上,可以实现灵活的枪口偏移配置。

操作步骤:
  1. 在Hierarchy中右键选择 Create Empty ,命名为 GunMuzzle
  2. 将其作为Player的子对象拖入。
  3. 在Inspector中调整其Local Position(如X=0.7, Y=0.3)。
  4. 在脚本中引用该Transform并用于实例化子弹。
public class ShootController : MonoBehaviour
{
    public Transform gunMuzzle;
    public GameObject bulletPrefab;

    void Update()
    {
        if (Input.GetButtonDown("Fire1"))
        {
            Vector3 spawnPos = gunMuzzle.position;
            Quaternion spawnRot = gunMuzzle.rotation;

            Instantiate(bulletPrefab, spawnPos, spawnRot);
        }
    }
}

优势分析
- 可视化编辑枪口位置,无需硬编码坐标。
- 支持动画驱动的枪口移动(如奔跑晃动)。
- 易于扩展多武器系统(切换不同muzzle点)。

此外,若角色有动画控制器(Animator),可通过Avatar Bone绑定 gunMuzzle 到骨骼上,实现更自然的射击姿态。

flowchart LR
    Player --> GunMuzzle
    GunMuzzle --> BulletSpawn[Instantiate at Muzzle]
    Player --> Animator
    Animator -->|Bone IK| GunMuzzle

上述流程图表明, GunMuzzle 既受Transform层级控制,也可由动画系统动态驱动,形成多层次的空间协调机制。

3.2 Rigidbody2D的动力学行为控制

Rigidbody2D 是2D物理系统的动力引擎,赋予物体质量、速度、加速度以及与其他物体相互作用的能力。不同于直接修改 Transform.position ,使用 Rigidbody2D 可实现符合物理规律的运动,如惯性、重力响应、碰撞反弹等。

3.2.1 设置Body Type(Dynamic、Kinematic、Static)对运动的影响

Unity提供三种基本的 Body Type 模式:

类型 特性 适用场景
Dynamic 受力、重力、碰撞影响,完全由物理引擎控制 玩家、敌人、可破坏物
Kinematic 不受力影响,但能触发碰撞,可通过代码设置速度 平台移动、AI巡逻路径
Static 不参与物理计算,仅用于碰撞阻挡 地形、墙壁
// 示例:切换为Kinematic以实现平台跟随
rigidbody2D.bodyType = RigidbodyType2D.Kinematic;
rigidbody2D.velocity = new Vector2(2f, 0f); // 手动赋值速度

注意事项
- Kinematic物体不会被其他Dynamic物体推动。
- Static物体不应频繁移动,否则会导致物理缓存失效,性能下降。

选择合适的Body Type直接影响游戏性能与行为预期。例如,在射击游戏中:
- 玩家通常设为 Dynamic ,以便响应爆炸冲击波;
- 子弹建议使用 Kinematic ,避免受重力干扰;
- 敌人根据AI设计可选两者之一。

3.2.2 使用AddForce与velocity直接赋值实现不同风格移动

Rigidbody2D 提供多种运动控制方式,各有优劣:

方式一: AddForce

适用于模拟真实推力、跳跃、冲撞等具有加速度感的行为。

public float moveForce = 10f;
private Rigidbody2D rb;

void Start()
{
    rb = GetComponent<Rigidbody2D>();
}

void FixedUpdate()
{
    float h = Input.GetAxisRaw("Horizontal");
    rb.AddForce(new Vector2(h * moveForce, 0), ForceMode2D.Force);
}

参数说明
- ForceMode2D.Force :持续力(考虑质量)。
- FixedUpdate 中调用,保证物理步调一致。
- 加速度效果明显,适合“漂移”手感。

方式二:直接设置 velocity

适用于需要即时响应的操作,如锁帧移动或平台跳跃。

void FixedUpdate()
{
    float h = Input.GetAxisRaw("Horizontal");
    rb.velocity = new Vector2(h * maxSpeed, rb.velocity.y);
}

特点
- 忽略质量,立即达到指定速度。
- 更适合“像素风”或“街机式”操控。

控制方式 加速度感 响应速度 推荐场景
AddForce ✅ 强 较慢 模拟真实推进
velocity 赋值 ❌ 无 即时 快节奏动作游戏

3.2.3 锁定Z轴旋转与限制线性/角度速度防止异常抖动

由于2D游戏仅关注XY平面,Z轴旋转往往会导致视觉错乱。应在Inspector中启用 Freeze Rotation Z ,或通过代码设置:

rb.constraints = RigidbodyConstraints2D.FreezeRotation | RigidbodyConstraints2D.FreezePositionY;

同时,为防止高速移动引发穿透(Tunneling)或数值溢出,建议限制最大速度:

void FixedUpdate()
{
    Vector2 currentVel = rb.velocity;
    // 限制X方向速度
    if (Mathf.Abs(currentVel.x) > maxSpeed)
    {
        currentVel.x = Mathf.Sign(currentVel.x) * maxSpeed;
        rb.velocity = currentVel;
    }

    // 或使用Dampening平滑减速
    rb.drag = 1.5f;
}

补充技巧
- 开启 Collision Detection: Continuous 可减少高速物体穿透风险。
- 对于子弹类对象,推荐使用 CCD (Continuous Collision Detection)

3.3 Collider2D的类型选择与碰撞响应设计

Collider2D定义了物体的“轮廓”,是实现碰撞检测和触发事件的基础。正确选择Collider类型不仅能提升精度,还能优化性能。

3.3.1 CircleCollider2D、BoxCollider2D与PolygonCollider2D适用场景对比

类型 精度 性能 适用对象
CircleCollider2D 高(圆形) ⭐⭐⭐⭐☆ 球体、粒子、简单敌人
BoxCollider2D 中(矩形) ⭐⭐⭐⭐⭐ 角色、平台、箱子
PolygonCollider2D 高(自定义) ⭐⭐☆☆☆ 复杂形状、非规则障碍
// 动态添加Collider示例
BoxCollider2D boxCol = gameObject.AddComponent<BoxCollider2D>();
boxCol.size = new Vector2(1.2f, 2.0f);

实践建议
- 尽量避免使用多个PolygonCollider2D,因其计算成本高。
- 对称角色优先使用Circle或Box组合近似。

3.3.2 触发器模式(Is Trigger)在拾取道具与伤害判定中的使用

勾选 Is Trigger 后,Collider不再产生物理阻挡,而是触发 OnTriggerEnter2D 回调。

private void OnTriggerEnter2D(Collider2D other)
{
    if (other.CompareTag("Pickup"))
    {
        Destroy(other.gameObject);
        AddAmmo(5);
    }
    else if (other.CompareTag("EnemyBullet"))
    {
        TakeDamage(10);
        Destroy(other.gameObject);
    }
}

关键点
- 至少一方需有 Rigidbody2D 才能触发。
- 常用于:拾取、陷阱、区域监测。

3.3.3 Composite Collider 2D合并多个碰撞体提升性能

当一个对象包含多个简单Collider(如多个Box),可使用 Composite Collider 2D 合并为单一几何体,显著降低CPU开销。

配置步骤:
  1. 添加多个 Edge Collider 2D Polygon Collider 2D
  2. 添加 Composite Collider 2D 组件。
  3. 勾选原有Collider的 Used By Composite
  4. 运行时自动生成优化后的碰撞网格。
// 启用复合碰撞体后,原Collider不可单独编辑
edgeCollider.usedByComposite = true;

性能对比(100个碎片)

配置方式 CPU耗时(ms/frame) 内存占用
多个独立PolygonCollider 4.8
使用Composite Collider 1.2
pie
    title Collider Performance Distribution
    “Composite Collider” : 25
    “Multiple Polygon” : 75

图表显示,复合碰撞体可节省约75%的物理计算负载。

3.4 组件间的数据交互与生命周期协同

Unity脚本的执行顺序与组件依赖关系决定了游戏逻辑的稳定性。理解 Awake , Start , Update , FixedUpdate 的调用时机,是避免空引用和逻辑错乱的前提。

3.4.1 Awake、Start、Update与FixedUpdate调用时机差异分析

方法 调用时间 执行频率 主要用途
Awake() 所有对象初始化后 一次 初始化引用、单例创建
Start() 第一次启用前 一次 依赖初始化(如获取组件)
Update() 每帧 ~60Hz 输入处理、UI更新
FixedUpdate() 每物理步长 ~50Hz(默认) 物理操作、力施加
public class PlayerHealth : MonoBehaviour
{
    private Rigidbody2D rb;

    void Awake()
    {
        rb = GetComponent<Rigidbody2D>(); // 可安全获取
    }

    void Start()
    {
        Debug.Log("Player Ready"); // 所有Awake之后执行
    }

    void Update()
    {
        HandleInput(); // 处理键盘输入
    }

    void FixedUpdate()
    {
        rb.AddForce(Vector2.right * speed); // 必须在此处调用物理相关
    }
}

错误示例
csharp void Update() { rb.velocity = ...; // 虽然可行,但可能导致物理步长不一致 }

3.4.2 利用OnCollisionEnter2D与OnTriggerEnter2D实现事件驱动逻辑

这两个回调是实现战斗、伤害、反馈的核心入口。

void OnCollisionEnter2D(Collision2D collision)
{
    if (collision.gameObject.CompareTag("Enemy"))
    {
        Vector2 impact = collision.relativeVelocity;
        float forceMag = impact.magnitude;

        if (forceMag > 5f)
        {
            PlayCrashEffect();
            ApplyKnockback(impact.normalized * 3f);
        }
    }
}

void OnTriggerEnter2D(Collider2D other)
{
    if (other.CompareTag("LaserTrap"))
    {
        StartCoroutine(DamageOverTime(5));
    }
}

扩展思路
- 结合 ContactFilter2D 提前筛选碰撞对象,减少不必要的回调。
- 使用事件总线(Event Bus)解耦伤害逻辑,提高模块复用性。

回调函数 条件 数据丰富度
OnCollisionEnter2D 双方均有Collider+Rigidbody,非Trigger 提供 Collision2D 包含相对速度、接触点
OnTriggerEnter2D 至少一方为Trigger 仅返回 Collider2D ,轻量但信息有限

合理运用这些机制,可构建出高度响应式的互动系统,支撑起整个2D射击游戏的核心玩法闭环。

4. C#脚本编程实现游戏逻辑(角色移动、射击控制、状态管理)

在Unity 2D射击游戏中,核心玩法的实现离不开高效、可维护且扩展性强的C#脚本系统。本章将深入探讨如何通过面向对象的设计原则与Unity引擎提供的生命周期机制,构建一个结构清晰、响应灵敏的游戏逻辑体系。重点聚焦于玩家输入处理、角色行为控制、射击机制优化以及基于状态机的行为切换策略。这些内容不仅是2D射击游戏的基础模块,更是现代游戏架构中“解耦”与“复用”理念的实际体现。

随着项目复杂度提升,简单的 Update() 轮询和硬编码逻辑已无法满足开发需求。因此,引入现代化的输入系统、对象池技术、事件驱动通信模式以及有限状态机(FSM),成为保障代码质量与运行效率的关键手段。本章将以“Wave Shooter”类游戏为背景,逐步构建从底层输入采集到高层行为决策的完整链条,并结合实际代码示例、性能分析图表与设计流程图,帮助开发者掌握工业级脚本编程的核心方法论。

4.1 玩家输入系统的抽象设计

4.1.1 使用Input System Package替代旧版Input Manager

Unity传统 Input Manager 存在诸多局限:配置繁琐、缺乏对多设备支持、难以动态重映射按键等。自Unity 2019年起, 新的Input System Package 已成为推荐方案,其模块化架构极大提升了输入处理的灵活性与可扩展性。

要启用新输入系统,需通过 Package Manager 安装 Input System 包,并在 Project Settings > Player > Active Input Handling 中设置为 “Input System (Preview)” 或 “Both”,以确保兼容性。

安装完成后,在Assets目录下创建 .inputactions 资源文件,使用可视化编辑器定义输入动作(Actions)。例如:

{
  "actions": [
    {
      "name": "Move",
      "type": "Value",
      "expectedControlType": "Vector2",
      "bindings": [
        { "path": "<Keyboard>/w", "groups": "Keyboard", "action": "Move" },
        { "path": "<Keyboard>/a", "groups": "Keyboard" },
        { "path": "<Keyboard>/s" },
        { "path": "<Keyboard>/d" },
        { "path": "<Gamepad>/leftStick" }
      ]
    },
    {
      "name": "Fire",
      "type": "Button",
      "expectedControlType": "Button",
      "bindings": [
        { "path": "<Mouse>/leftButton" },
        { "path": "<Gamepad>/buttonSouth" }
      ]
    }
  ]
}

此JSON描述了一个名为 Move 的二维向量输入(支持键盘WSAD或手柄左摇杆)和一个 Fire 按钮事件(鼠标左键或手柄A键)。Unity会自动生成对应的C#类(如 PlayerInputActions ),供脚本调用。

参数说明:
  • "type" :决定输入类型, Value 表示持续值, Button 表示按下/释放事件。
  • "expectedControlType" :指定数据格式, Vector2 对应二维方向, Button 触发布尔状态。
  • "bindings" :绑定具体物理设备路径,支持跨平台统一接口。

该设计实现了 输入无关性 ——无论用户使用键盘还是手柄,上层逻辑只需读取 move.ReadValue<Vector2>() 即可获取归一化方向向量,无需判断设备类型。

4.1.2 定义Move与Fire动作映射并绑定键盘/手柄设备

一旦完成 .inputactions 配置,即可在Player控制器中集成输入监听。以下是一个典型的绑定实现:

using UnityEngine;
using UnityEngine.InputSystem;

public class PlayerInputHandler : MonoBehaviour
{
    private PlayerInputActions inputActions;
    public Vector2 MoveDirection { get; private set; }
    public bool IsFiring { get; private set; }

    void Awake()
    {
        // 初始化输入动作
        inputActions = new PlayerInputActions();
        // 绑定Move动作
        inputActions.Player.Move.performed += ctx => 
            MoveDirection = ctx.ReadValue<Vector2>().normalized;
        inputActions.Player.Move.canceled += ctx => 
            Move接纳irection = Vector2.zero;

        // 绑定Fire动作
        inputActions.Player.Fire.started += _ => IsFiring = true;
        inputActions.Player.Fire.canceled += _ => IsFiring = false;
    }

    void OnEnable() => inputActions.Enable();
    void OnDisable() => inputActions.Disable();
}
代码逻辑逐行解读:
  1. 创建 PlayerInputActions 实例,加载预设的动作集;
  2. 使用 performed 回调捕获有效输入(如按键按下或摇杆移动),读取当前值并归一化;
  3. canceled 回调用于处理输入中断(如松开按键),重置方向为零向量;
  4. Fire事件采用 started canceled 分别表示“开始射击”与“停止射击”;
  5. OnEnable/OnDisable 控制输入监听的生命周期,避免空引用异常。

最佳实践建议 :不要在 Update() 中频繁调用 ReadValue() ,而应在事件回调中更新状态变量,减少每帧开销。

设备类型 支持输入方式 自动识别 多设备共存
键盘 WASD / 箭头键 同时激活优先级高的设备
鼠标 左右键、滚轮 可配合键盘瞄准
手柄 摇杆、扳机键 支持双人本地分屏

该表格展示了不同设备在新输入系统下的兼容能力。Unity会根据连接状态自动选择主控设备(Primary Device),也可手动指定。

graph TD
    A[Input Device Connected] --> B{Is Gamepad?}
    B -- Yes --> C[Use Left Stick for Move]
    B -- No --> D{Is Keyboard?}
    D -- Yes --> E[Map WASD to Move Action]
    C & E --> F[Trigger Move.performed Event]
    F --> G[Update MoveDirection in Script]

上述流程图揭示了输入事件从硬件到底层脚本的传递路径。这种事件驱动模型显著降低了轮询检测的CPU占用率。

4.1.3 实现输入去抖动与灵敏度调节功能

真实环境中,用户操作常伴随误触或微小晃动(尤其是手柄摇杆漂移),需加入滤波机制提高体验。

去抖动处理(Debouncing)

对于按钮类输入(如Fire),可添加最小触发间隔防止连发过快:

public class DebouncedFireInput : MonoBehaviour
{
    [SerializeField] private float minInterval = 0.1f;
    private float lastFireTime;

    public bool CanFireNow()
    {
        if (Time.time - lastFireTime >= minInterval)
        {
            lastFireTime = Time.time;
            return true;
        }
        return false;
    }
}
灵敏度调节

针对模拟输入(如摇杆),可通过非线性映射增强精细控制:

public static class InputSmoother
{
    public static Vector2 ApplyDeadzoneAndCurve(Vector2 raw, 
        float deadzone = 0.1f, float exponent = 2f)
    {
        float magnitude = raw.magnitude;
        if (magnitude < deadzone) return Vector2.zero;

        // 应用死区后进行指数加速
        Vector2 normalized = raw.normalized;
        float adjustedMag = Mathf.Pow((magnitude - deadzone) / (1f - deadzone), exponent);
        return normalized * Mathf.Clamp01(adjustedMag);
    }
}
参数说明:
  • deadzone :忽略小于该值的微小输入,消除漂移;
  • exponent :大于1时提供“低速精准,高速快速”的手感曲线;
  • 输出经钳制确保不超过单位圆范围。

整合至输入处理器中:

inputActions.Player.Move.performed += ctx =>
{
    Vector2 raw = ctx.ReadValue<Vector2>();
    MoveDirection = InputSmoother.ApplyDeadzoneAndCurve(raw, 0.1f, 2.5f);
};

此优化使新手能轻松操控,高手亦可实现微操,兼顾普适性与专业性。

4.2 角色移动与射击控制脚本编写

4.2.1 编写PlayerMovement脚本实现平滑加速与边界限制

角色移动是游戏交互的第一感知层,直接影响操作手感。理想的移动应具备 加速度缓冲 最大速度限制 屏幕边界约束 三大特性。

public class PlayerMovement : MonoBehaviour
{
    [Header("Movement Settings")]
    [SerializeField] private float maxSpeed = 8f;
    [SerializeField] private float acceleration = 20f;
    [SerializeField] private float deceleration = 30f;
    [SerializeField] private Vector2 movementBounds = new Vector2(8f, 5f);

    private Rigidbody2D rb;
    private Vector2 targetVelocity;

    void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
        rb.constraints = RigidbodyConstraints2D.FreezeRotation; // 锁定Z轴旋转
    }

    public void SetTargetVelocity(Vector2 direction)
    {
        targetVelocity = direction * maxSpeed;
    }

    void FixedUpdate()
    {
        Vector2 currentVel = rb.velocity;
        Vector2 desiredVel = Vector2.zero;

        if (targetVelocity.sqrMagnitude > 0.1f)
        {
            desiredVel = Vector2.MoveTowards(currentVel, targetVelocity, 
                acceleration * Time.fixedDeltaTime);
        }
        else
        {
            desiredVel = Vector2.MoveTowards(currentVel, Vector2.zero, 
                deceleration * Time.fixedDeltaTime);
        }

        rb.velocity = ClampToBoundaries(desiredVel);
    }

    private Vector2 ClampToBoundaries(Vector2 velocity)
    {
        Vector3 newPos = transform.position + (Vector3)velocity * Time.fixedDeltaTime;
        float clampedX = Mathf.Clamp(newPos.x, -movementBounds.x, movementBounds.x);
        float clampedY = Mathf.Clamp(newPos.y, -movementBounds.y, movementBounds.y);
        transform.position = new Vector3(clampedX, clampedY, transform.position.z);
        return velocity; // 不修改速度本身,仅限制位置
    }
}
逻辑分析:
  • 使用 Rigidbody2D 物理组件而非直接修改 Transform.position ,保证与其他物理对象正确交互;
  • SetTargetVelocity() 接收外部输入方向,转换为目标速度;
  • FixedUpdate() 中使用 Vector2.MoveTowards() 实现渐进式加速/减速,模拟惯性;
  • 边界检查通过预测下一帧位置实现,避免穿墙;
  • 保留速度向量不变,仅修正位置,防止碰撞反弹异常。

4.2.2 设计ShootController类处理射速冷却(Cooldown)与弹道方向

射击控制需精确管理发射频率与子弹方向。以下是高性能的射击控制器实现:

public class ShootController : MonoBehaviour
{
    [Header("Shooting Settings")]
    [SerializeField] private GameObject bulletPrefab;
    [SerializeField] private Transform firePoint;
    [SerializeField] private float fireRate = 10f; // 子弹/秒
    [SerializeField] private float bulletSpeed = 15f;

    private float nextFireTime;
    private ObjectPool bulletPool;

    void Awake()
    {
        bulletPool = ObjectPool.CreateInstance(bulletPrefab, 50);
    }

    public void AttemptFire(Vector2 aimDirection)
    {
        if (Time.time < nextFireTime) return;

        GameObject bulletGO = bulletPool.Get();
        bulletGO.transform.position = firePoint.position;
        bulletGO.transform.right = aimDirection;

        Bullet bullet = bulletGO.GetComponent<Bullet>();
        bullet.SetVelocity(aimDirection * bulletSpeed);

        nextFireTime = Time.time + 1f / fireRate;
    }
}
参数说明:
  • fireRate :每秒最多发射子弹数;
  • bulletSpeed :初始速度大小;
  • firePoint :枪口位置,通常为子对象空节点;
  • ObjectPool :预先实例化的对象容器,避免GC压力。
关键点解析:
  • AttemptFire() 接受瞄准方向作为参数,支持鼠标指向射击;
  • 时间戳比较实现冷却机制,无需协程或Invoke;
  • 利用 ObjectPool.Get() 获取可用子弹,极大降低Instantiate开销。

4.2.3 引入Object Pooling减少Instantiate/Destroy开销

动态生成子弹若频繁调用 Instantiate Destroy ,会导致内存碎片与帧率波动。对象池解决方案如下:

public class ObjectPool
{
    private Queue<GameObject> pool = new Queue<GameObject>();
    private GameObject prefab;

    public static ObjectPool CreateInstance(GameObject prefab, int initialSize)
    {
        var instance = new ObjectPool();
        instance.prefab = prefab;
        for (int i = 0; i < initialSize; i++)
        {
            GameObject go = GameObject.Instantiate(prefab);
            go.SetActive(false);
            instance.pool.Enqueue(go);
        }
        return instance;
    }

    public GameObject Get()
    {
        if (pool.Count == 0)
            ExpandPool(); // 动态扩容

        GameObject obj = pool.Dequeue();
        obj.SetActive(true);
        return obj;
    }

    public void ReturnToPool(GameObject obj)
    {
        obj.SetActive(false);
        obj.transform.SetParent(null); // 解除父子关系
        pool.Enqueue(obj);
    }

    private void ExpandPool() => pool.Enqueue(GameObject.Instantiate(prefab));
}
性能对比表(100次发射操作):
方法 平均耗时(ms) GC Alloc(MB) 帧稳定性
Instantiate/Destroy 47.3 6.8 ❌ 波动大
Object Pooling 8.1 0.0 ✅ 流畅
classDiagram
    class ObjectPool {
        -Queue~GameObject~ pool
        -GameObject prefab
        +Get() GameObject
        +ReturnToPool(GameObject)
        -ExpandPool()
    }

    class ShootController {
        -ObjectPool bulletPool
        +AttemptFire(Vector2)
    }

    ShootController --> ObjectPool : uses

类图显示了 ShootController 依赖 ObjectPool 的关系,体现了职责分离原则。

4.3 游戏状态机的设计与实现

4.3.1 定义PlayerState枚举(Idle、Moving、Shooting、Dead)

状态机是管理角色行为流的核心工具。首先定义状态枚举:

public enum PlayerState
{
    Idle,
    Moving,
    Shooting,
    Reloading,
    Dead
}

每个状态代表一种行为模式,互斥且明确。

4.3.2 构建有限状态机框架(FSM)实现状态切换逻辑

public abstract class State
{
    public virtual void Enter() { }
    public virtual void Update() { }
    public virtual void Exit() { }
}

public class FSM<T> where T : struct, IConvertible
{
    private Dictionary<T, State> states = new Dictionary<T, State>();
    private T currentState;

    public void AddState(T stateEnum, State state) => states[stateEnum] = state;

    public void ChangeState(T newState)
    {
        if (!states.ContainsKey(newState)) return;

        states[currentState]?.Exit();
        currentState = newState;
        states[currentState]?.Enter();
    }

    public void Update() => states[currentState]?.Update();
}

应用于玩家控制器:

public class PlayerStateMachine : MonoBehaviour
{
    private FSM<PlayerState> fsm = new FSM<PlayerState>();

    void Start()
    {
        fsm.AddState(PlayerState.Idle, new IdleState(this));
        fsm.AddState(PlayerState.Moving, new MovingState(this));
        fsm.ChangeState(PlayerState.Idle);
    }

    void Update() => fsm.Update();
}

4.3.3 结合Animator Controller同步视觉反馈

在状态变更时通知动画系统:

public class MovingState : State
{
    private PlayerStateMachine machine;

    public MovingState(PlayerStateMachine m) => machine = m;

    public override void Enter()
    {
        machine.animator.SetBool("IsMoving", true);
    }

    public override void Update()
    {
        if (machine.input.IsMoving == false)
            machine.fsm.ChangeState(PlayerState.Idle);
    }

    public override void Exit()
    {
        machine.animator.SetBool("IsMoving", false);
    }
}

通过联动Animator参数,实现动作流畅过渡,增强沉浸感。

4.4 脚本间的通信机制

4.4.1 使用事件委托(Action/EventHandler)解耦模块

避免直接引用,使用事件传递消息:

public static class GameEvents
{
    public static Action<int> OnScoreChanged;
    public static Action OnPlayerDeath;
}

// 发布者
void OnTriggerEnter2D(Collider2D col)
{
    if (col.CompareTag("Enemy"))
    {
        GameEvents.OnPlayerDeath?.Invoke();
    }
}

// 订阅者(UIManager)
void OnEnable() => GameEvents.OnPlayerDeath += HandleGameOver;
void OnDisable() => GameEvents.OnPlayerDeath -= HandleGameOver;

4.4.2 借助Singleton模式访问全局管理器(如GameManager)

public class GameManager : MonoBehaviour
{
    public static GameManager Instance { get; private set; }

    void Awake()
    {
        if (Instance != null && Instance != this)
            Destroy(gameObject);
        else
            Instance = this;
    }
}

提供全局访问点,集中管理游戏流程、分数、波次等共享数据。

5. 子弹发射、碰撞检测与生命周期管理机制设计

在2D射击类游戏中,子弹系统是核心交互逻辑的关键组成部分。它不仅影响玩家的操作反馈和战斗节奏,还直接关系到游戏性能表现与物理行为的准确性。一个高效的子弹系统需要兼顾实时性、可扩展性以及资源利用率。本章节将深入剖析从子弹预制体创建、动态发射控制、多层次碰撞检测到完整生命周期自动化管理的整套技术链条。通过结合Unity的2D物理引擎、对象池优化策略与事件驱动架构,构建一套稳定且高性能的弹道处理机制。

我们将以“2D Wave Shooter”项目为背景,围绕 Bullet Prefab 的设计原则、基于 Collider2D 的精准碰撞过滤、以及利用计时器与屏幕边界判断实现自动回收等关键技术点展开详细讲解。整个流程将贯穿代码实现、参数调优、性能监控与可视化调试手段,确保开发者能够掌握从理论到落地的全链路开发能力。

5.1 子弹预制体的设计与发射逻辑

子弹作为高频生成的游戏对象,其设计必须遵循轻量化、模块化与复用性强的原则。使用Unity的Prefab机制可以有效封装子弹的所有属性与行为,使其能够在运行时被快速实例化并统一管理。更重要的是,在高频率射击场景下,频繁调用 Instantiate Destroy 会导致GC(垃圾回收)压力剧增,进而引发帧率波动。因此,引入对象池(Object Pooling)成为必要选择。

5.1.1 创建Bullet Prefab并配置速度与生存时间

首先,在Unity编辑器中创建一个新的Sprite对象用于表示子弹外观。建议采用简洁的圆形或矩形图形,并导入至 Sprites 文件夹。随后将其拖入Hierarchy创建实例,重命名为 Bullet ,并添加以下关键组件:

  • Rigidbody2D :启用2D物理运动,设置Body Type为 Kinematic ,避免受重力影响。
  • CircleCollider2D BoxCollider2D :作为碰撞触发器,勾选 Is Trigger 以便进行无物理响应的检测。
  • Sprite Renderer :控制显示状态。
  • 自定义脚本 Bullet.cs :封装移动、生命周期与回收逻辑。
// Bullet Prefab 结构示例(Inspector视图模拟)
- GameObject: Bullet
  - Components:
    - Transform
    - SpriteRenderer
    - CircleCollider2D (Is Trigger = true)
    - Rigidbody2D (Body Type: Kinematic, Gravity Scale: 0)
    - Bullet (Script)

Bullet.cs 脚本中定义基本属性:

using UnityEngine;

public class Bullet : MonoBehaviour
{
    [SerializeField] private float speed = 10f;           // 移动速度
    [SerializeField] private float lifeTime = 3f;         // 最大存活时间
    [SerializeField] private int damage = 1;              // 伤害值
    private Vector2 direction;                            // 发射方向

    private void OnEnable()
    {
        Invoke("DeactivateAfterLife", lifeTime); // 启动后倒计时回收
    }

    public void SetDirection(Vector2 dir)
    {
        direction = dir.normalized;
    }

    private void Update()
    {
        transform.position += (Vector3)(direction * speed * Time.deltaTime);
    }

    private void DeactivateAfterLife()
    {
        gameObject.SetActive(false); // 回收到对象池
    }
}
代码逻辑逐行分析:
行号 说明
6-9 序列化字段允许在Inspector中调整速度、寿命和伤害,便于平衡测试
14 OnEnable() 是对象激活时调用的方法,适合初始化定时任务
15 使用 Invoke 延迟执行 DeactivateAfterLife 方法,防止无限飞行
20 SetDirection 提供外部接口设置发射方向,支持不同角度射击
25 Update 中更新位置,使用 Time.deltaTime 实现帧率无关移动

该设计实现了子弹的基本飞行能力,同时通过 SetActive(false) 配合对象池实现非销毁式回收,极大降低GC压力。

5.1.2 通过Instantiate实例化子弹并设置初始方向

传统的子弹发射方式如下所示:

public class ShootController : MonoBehaviour
{
    [SerializeField] private GameObject bulletPrefab;
    [SerializeField] private Transform firePoint; // 枪口位置
    [SerializeField] private float fireRate = 0.5f;
    private float nextFireTime;

    private void Update()
    {
        if (Input.GetButton("Fire1") && Time.time > nextFireTime)
        {
            Fire();
            nextFireTime = Time.time + fireRate;
        }
    }

    private void Fire()
    {
        GameObject bullet = Instantiate(bulletPrefab, firePoint.position, Quaternion.identity);
        Vector2 shootDir = Camera.main.ScreenToWorldPoint(Input.mousePosition) - firePoint.position;
        bullet.GetComponent<Bullet>().SetDirection(shootDir);
    }
}
参数说明:
  • bulletPrefab :引用预设的子弹Prefab。
  • firePoint :代表枪口的世界坐标位置,通常为空子对象挂载于玩家身上。
  • fireRate :两次射击之间的最小间隔,防止过快连发。
  • shootDir :由鼠标位置计算出的朝向向量。
执行逻辑分析:
  1. 每帧检测输入是否按下左键;
  2. 判断当前时间是否超过下次可射击时间;
  3. 调用 Instantiate 实例化子弹;
  4. 计算从枪口指向鼠标的单位向量并传入 SetDirection
  5. 设置冷却时间。

虽然此方法功能完整,但在密集射击时会产生大量临时对象,导致内存抖动。

5.1.3 使用PoolManager回收而非销毁子弹对象

为解决频繁实例化问题,我们设计一个通用的对象池管理器 PoolManager

using System.Collections.Generic;
using UnityEngine;

public class PoolManager : MonoBehaviour
{
    public static PoolManager Instance;

    [System.Serializable]
    public class ObjectPool
    {
        public string tag;
        public GameObject prefab;
        public int size;
    }

    public List<ObjectPool> pools;
    private Dictionary<string, Queue<GameObject>> poolDictionary;

    private void Awake()
    {
        Instance = this;
        poolDictionary = new Dictionary<string, Queue<GameObject>>();

        foreach (var pool in pools)
        {
            Queue<GameObject> objectQueue = new Queue<GameObject>();
            for (int i = 0; i < pool.size; i++)
            {
                GameObject obj = Instantiate(pool.prefab);
                obj.SetActive(false);
                obj.transform.SetParent(transform);
                objectQueue.Enqueue(obj);
            }
            poolDictionary.Add(pool.tag, objectQueue);
        }
    }

    public GameObject SpawnFromPool(string tag, Vector3 position, Quaternion rotation)
    {
        if (!poolDictionary.ContainsKey(tag))
        {
            Debug.LogError("Tag " + tag + " not found in pool.");
            return null;
        }

        GameObject objectToSpawn = poolDictionary[tag].Dequeue();
        objectToSpawn.SetActive(true);
        objectToSpawn.transform.position = position;
        objectToSpawn.transform.rotation = rotation;

        // 放回队列尾部(延迟回收时调用Enqueue)
        StartCoroutine(RequeueAfterDelay(objectToSpawn, tag));

        return objectToSpawn;
    }

    private System.Collections.IEnumerator RequeueAfterDelay(GameObject go, string tag)
    {
        yield return new WaitUntil(() => !go.activeSelf); // 等待其被禁用
        poolDictionary[tag].Enqueue(go);
    }
}
Mermaid 流程图:对象池工作原理
graph TD
    A[请求获取子弹] --> B{是否有可用对象?}
    B -- 是 --> C[取出队列头部对象]
    B -- 否 --> D[创建新对象或报错]
    C --> E[激活对象并设置位置/方向]
    E --> F[加入场景运行]
    F --> G[碰撞或超时后调用SetActive(false)]
    G --> H[等待协程检测到失活]
    H --> I[重新入队尾部]
    I --> J[可供下次复用]
表格:对象池配置示例
Tag Prefab Size 描述
PlayerBullet Bullet 50 玩家发射的标准子弹池
EnemyBullet RedBullet 30 敌人使用的红色子弹
Explosion FX_Explosion 20 击中目标后的爆炸特效
使用方式修改 Fire() 方法:
private void Fire()
{
    Vector2 shootDir = Camera.main.ScreenToWorldPoint(Input.mousePosition) - firePoint.position;
    GameObject bullet = PoolManager.Instance.SpawnFromPool("PlayerBullet", firePoint.position, Quaternion.identity);
    bullet.GetComponent<Bullet>().SetDirection(shootDir);
}

这样就完成了从原始 Instantiate 到高效对象池的迁移,显著提升性能稳定性。

5.2 基于Collider2D的多层次碰撞检测体系

准确而高效的碰撞检测是决定射击体验真实感的核心环节。Unity的2D物理系统提供了丰富的API来支持不同类型的目标识别与响应处理。我们需要合理运用LayerMask、ContactFilter2D以及触发回调函数来构建一个多层级的判定机制。

5.2.1 区分敌我层(LayerMask)避免自伤

Unity允许最多32个图层(Layers),我们可以自定义两个专用图层:

  • Player
  • Enemy

然后在Project Settings > Physics 2D 中配置 Layer Collision Matrix ,确保:
- Player Player 不发生碰撞
- Enemy Enemy 不发生碰撞
- PlayerBullet 只与 Enemy 碰撞
- EnemyBullet 只与 Player 碰撞

这能从根本上杜绝误伤问题。

在代码中也可通过LayerMask进行条件判断:

private void OnTriggerEnter2D(Collider2D other)
{
    if (other.CompareTag("Enemy"))
    {
        ApplyDamage(other.gameObject);
        gameObject.SetActive(false);
    }
    else if (other.CompareTag("Wall"))
    {
        gameObject.SetActive(false);
    }
}

5.2.2 在OnCollisionEnter2D中判断撞击目标类型并触发伤害

当子弹带有 Is Trigger = true 时,应使用 OnTriggerEnter2D 而非 OnCollisionEnter2D 。以下是完整的伤害分发逻辑:

private void OnTriggerEnter2D(Collider2D other)
{
    if (CompareLayer(other, LayerMask.NameToLayer("Enemy")))
    {
        IDamageable enemy = other.GetComponent<IDamageable>();
        if (enemy != null)
        {
            enemy.TakeDamage(damage);
        }
        PlayHitEffect();
        gameObject.SetActive(false);
    }
}

private bool CompareLayer(Collider2D col, int targetLayer)
{
    return col.gameObject.layer == targetLayer;
}

private void PlayHitEffect()
{
    // 播放粒子特效或声音
    VFXManager.Instance.PlayHitEffect(transform.position);
}

其中 IDamageable 是一个接口,定义通用受伤行为:

public interface IDamageable
{
    void TakeDamage(int amount);
}

敌人脚本实现该接口即可接入伤害系统,实现解耦。

5.2.3 使用ContactFilter2D进行条件筛选提高性能

在某些高级场景中,如扇形AOE检测或特定标签过滤,可借助 Physics2D.OverlapCircleAll 配合 ContactFilter2D 进行精细化筛选:

ContactFilter2D filter = new ContactFilter2D();
filter.SetLayerMask(LayerMask.GetMask("Enemy"));
filter.useTriggers = true;

List<Collider2D> results = new List<Collider2D>();
int count = Physics2D.OverlapCircle(collider.bounds.center, 2f, filter, results);

foreach (var hit in results)
{
    hit.GetComponent<IDamageable>()?.TakeDamage(2);
}

这种方式比遍历所有碰撞体更高效,尤其适用于区域技能或爆炸范围判定。

表格:碰撞检测方法对比
方法 适用场景 性能开销 是否支持Layer过滤
OnTriggerEnter2D 单个碰撞触发
Physics2D.Raycast 射线探测障碍物
Physics2D.OverlapArea 区域内多个对象检测 中高 ✅(配合ContactFilter)
Collider.enabled 动态启停碰撞体 极低

5.3 子弹生命周期的自动化管理

为了防止子弹无限飞行造成资源浪费或逻辑错误,必须建立完善的生命周期终止机制。理想情况下,一个子弹应在满足任一条件时立即结束其存在周期:
1. 触发碰撞;
2. 超出屏幕可视区域;
3. 达到预设生存时限。

5.3.1 添加Time-to-Live计时器防止无限飞行

已在 Bullet.cs 中通过 Invoke("DeactivateAfterLife", lifeTime) 实现基础TTL机制。为进一步增强可控性,可替换为Coroutine以支持暂停与重置:

private IEnumerator LifeTimer()
{
    float elapsed = 0f;
    while (elapsed < lifeTime)
    {
        elapsed += Time.deltaTime;
        yield return null;
    }
    gameObject.SetActive(false);
}

启动方式改为:

private void OnEnable()
{
    StopAllCoroutines(); // 防止多重计时
    StartCoroutine(LifeTimer());
}

5.3.2 检测屏幕外区域自动回收

利用Camera将世界坐标转换为视口坐标,判断是否完全离开屏幕:

private void Update()
{
    transform.position += (Vector3)(direction * speed * Time.deltaTime);

    Vector3 viewPos = Camera.main.WorldToViewportPoint(transform.position);
    if (viewPos.x < -0.1f || viewPos.x > 1.1f || viewPos.y < -0.1f || viewPos.y > 1.1f)
    {
        gameObject.SetActive(false);
    }
}

此处留有一定缓冲区(±0.1),防止因插值导致视觉穿帮。

5.3.3 碰撞后播放粒子特效并延迟回收

为提升打击感,可在命中时播放特效并延迟回收:

private void OnTriggerEnter2D(Collider2D other)
{
    if (other.CompareTag("Enemy") || other.CompareTag("Wall"))
    {
        PlayHitEffect();
        Invoke("ReturnToPool", 0.1f); // 延迟0.1秒再回收,保证特效播放
    }
}

private void ReturnToPool()
{
    gameObject.SetActive(false);
}

private void PlayHitEffect()
{
    GameObject effect = PoolManager.Instance.SpawnFromPool("Explosion", transform.position, Quaternion.identity);
    // 特效自身负责在播放完毕后回收
}
Mermaid 流程图:子弹完整生命周期
stateDiagram-v2
    [*] --> Spawned
    Spawned --> Moving: 启动移动逻辑
    Moving --> Collide: 触发OnTriggerEnter2D
    Moving --> OutOfBounds: WorldToViewportPoint超出范围
    Moving --> TTLExpired: 生存时间到达上限
    Collide --> PlayEffect: 播放VFX/SFX
    OutOfBounds --> Deactivate: 直接回收
    TTLExpired --> Deactivate: 直接回收
    PlayEffect --> Delay: Wait 0.1s
    Delay --> Deactivate: 回收至对象池
    Deactivate --> [*]

这一闭环管理机制确保了每一颗子弹都能被精确追踪与安全清理,是现代2D射击游戏不可或缺的技术基石。

6. 敌人波浪(Wave)系统设计与Wave Manager脚本实现

在2D射击类游戏中,敌人“波次”机制是构建游戏节奏和难度曲线的核心系统之一。它不仅决定了玩家面对敌人的频率、数量与行为模式,还直接影响游戏的可玩性、挑战性和沉浸感。一个精心设计的波浪系统可以引导玩家逐步适应战斗强度,同时通过递增的复杂度激发其策略调整能力。本章将深入剖析敌人波次系统的架构设计原则,并以Unity C#脚本为核心,实现一个可扩展、状态可控的 WaveManager 管理器,结合协程调度、事件监听与AI行为编程,完成从数据建模到运行时控制的完整闭环。

6.1 波次系统的需求分析与架构设计

现代2D射击游戏中的敌人波次系统通常需要满足以下几个核心需求:动态生成敌人、按阶段递增难度、支持多种敌人类型混合出现、具备暂停/恢复机制以及能够响应玩家表现进行自适应调整。为了实现这些功能,必须首先建立清晰的数据结构与逻辑分层,避免将所有逻辑耦合在单一脚本中,从而提升系统的可维护性与扩展性。

6.1.1 定义Wave数据结构(Enemy Count, Spawn Rate, Difficulty)

要实现波次驱动的游戏流程,首要任务是定义每一“波”敌人的基本属性。我们可以通过C#中的类或结构体来封装这些信息,使其成为可序列化对象,便于在Inspector面板中配置或通过ScriptableObject进行管理。

using UnityEngine;

[CreateAssetMenu(fileName = "NewWaveData", menuName = "Wave System/Wave Data")]
public class WaveData : ScriptableObject
{
    [Header("敌人基本信息")]
    public GameObject enemyPrefab;               // 敌人预制体
    public int enemyCount = 10;                  // 当前波次生成总数
    public float spawnInterval = 1.0f;           // 生成间隔(秒)
    [Header("难度参数")]
    public float healthMultiplier = 1.0f;        // 血量倍率
    public float speedMultiplier = 1.0f;         // 移动速度倍率
    public int scoreValue = 100;                 // 击杀得分
    [Header("高级设置")]
    public bool allowMultipleTypes = false;      // 是否允许多种敌人
    public WaveEnemy[] additionalEnemies;        // 额外敌人列表(用于混合波次)
}

[System.Serializable]
public class WaveEnemy
{
    public GameObject prefab;
    public int weight; // 权重决定出现概率
}

代码逻辑逐行解读:

  • [CreateAssetMenu] 属性允许我们在Project窗口右键创建该资源,极大方便了配置管理。
  • enemyPrefab 是基础敌人类型,适用于简单波次;而 additionalEnemies 数组支持更复杂的敌人组合。
  • 所有数值如 healthMultiplier speedMultiplier 均为相对基准值的缩放因子,便于统一平衡调整。
  • 使用 ScriptableObject 而非普通类,是因为它可在编辑器中持久保存且无需挂载至GameObject,适合做配置文件。

该设计的优势在于解耦了“数据”与“行为”,使得策划人员可以在不修改代码的情况下调整关卡难度。例如,第5波可能使用高权重的小型快速敌人,而Boss波则单独设定特殊预制体和极低生成频率。

参数名 类型 描述 示例值
enemyPrefab GameObject 主要敌人预制体引用 Enemy_Melee.prefab
enemyCount int 总生成数量 15
spawnInterval float 每次生成间隔(秒) 0.8f
healthMultiplier float 生命值增强系数 1.3f
speedMultiplier float 移动速度放大比例 1.2f
scoreValue int 击杀后获得分数 200
allowMultipleTypes bool 是否启用多类型敌人 true

此外,这种结构天然支持后续扩展,比如加入“环境效果触发”、“背景音乐切换”甚至“天气变化”等附加事件字段。

6.1.2 设计波次递增算法(线性增长、指数难度曲线)

随着游戏进程推进,敌人波次应呈现合理的难度上升趋势。常见的增长模型包括线性增长、指数增长和S型平滑曲线。选择合适的数学函数对保持玩家体验至关重要——过于陡峭会导致挫败感,过于平缓则缺乏紧张感。

线性难度增长示例:
public static float LinearDifficulty(int currentWave, float baseValue, float incrementPerWave)
{
    return baseValue + (currentWave - 1) * incrementPerWave;
}

此公式适用于稳定渐进式难度提升,例如每波增加2个敌人。

指数难度增长(推荐用于后期高压段落):
public static float ExponentialDifficulty(int currentWave, float baseValue, float growthRate)
{
    return baseValue * Mathf.Pow(growthRate, currentWave - 1);
}

例如设置 growthRate = 1.2f ,则第1波为10只敌人,第4波可达约17只,第8波超过35只,形成显著压迫感。

自定义曲线插值(使用AnimationCurve灵活控制):
[SerializeField] private AnimationCurve difficultyCurve;

public float GetDifficultyFactor(int waveNumber)
{
    return difficultyCurve.Evaluate(waveNumber);
}

图:通过AnimationCurve可视化调节难度曲线

上述三种方式可通过下表对比其适用场景:

类型 公式 优点 缺点 适用阶段
线性增长 y = a + b(n-1) 易理解,控制精准 后期增长乏力 初级教学关卡
指数增长 y = a × r^(n−1) 快速制造压力 容易失控 中后期高强度战斗
曲线映射 y = f(n) via Curve 极高灵活性,可视化调节 需预先调参 商业项目正式版本

实际开发中建议采用 混合策略 :前期使用线性增长确保学习曲线平稳,中期过渡至指数增长,后期引入随机扰动项(±10%浮动)防止模式化预测,进一步提升不可预知性。

graph TD
    A[开始新游戏] --> B{是否第一波?}
    B -- 是 --> C[加载Wave 1配置]
    B -- 否 --> D[计算当前难度因子]
    D --> E[根据曲线获取enemyCount/spawnRate]
    E --> F[实例化WaveManager.StartWave()]
    F --> G[生成敌人直到数量达标]
    G --> H{全部敌人死亡?}
    H -- 是 --> I[播放升级动画]
    I --> J[wave++ → 返回D]
    H -- 否 --> K[继续监测]

上述流程图展示了波次推进的整体控制流,体现了状态判断与循环迭代的设计思想。

6.2 Wave Manager的核心逻辑实现

WaveManager 作为整个波次系统的中枢控制器,负责协调敌人的生成、波次切换、游戏状态同步及外部通信。考虑到其全局唯一性,采用单例模式是最合理的选择。同时,利用Unity的协程机制可实现非阻塞的时间调度,避免使用Update轮询带来的性能浪费。

6.2.1 编写WaveManager单例控制整体节奏

以下是一个完整的 WaveManager 基础框架:

using System.Collections;
using UnityEngine;
using UnityEngine.Events;

public class WaveManager : MonoBehaviour
{
    public static WaveManager Instance;

    [Header("核心配置")]
    public WaveData[] waveConfigurations;     // 所有波次配置数组
    public Transform[] spawnPoints;          // 多个出生点位
    public float preWaveDelay = 2.0f;        // 每波开始前延迟
    public float postWaveGracePeriod = 3.0f; // 波结束后等待判定时间

    [Header("事件回调")]
    public UnityEvent<int> OnWaveStarted;
    public UnityEvent<int> OnAllEnemiesDefeated;

    private int currentWaveIndex = 0;
    private int enemiesAlive = 0;
    private bool isSpawning = false;

    private void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
        }
    }

    private void Start()
    {
        StartCoroutine(StartNextWave());
    }

    public IEnumerator StartNextWave()
    {
        yield return new WaitForSeconds(preWaveDelay);

        if (currentWaveIndex >= waveConfigurations.Length)
        {
            Debug.Log("所有波次已完成!");
            HandleGameVictory();
            yield break;
        }

        var currentWave = waveConfigurations[currentWaveIndex];
        OnWaveStarted?.Invoke(currentWaveIndex + 1);

        isSpawning = true;
        enemiesAlive = 0;

        for (int i = 0; i < currentWave.enemyCount; i++)
        {
            Vector3 spawnPos = spawnPoints[Random.Range(0, spawnPoints.Length)].position;
            GameObject enemy = Instantiate(currentWave.enemyPrefab, spawnPos, Quaternion.identity);

            // 动态注入属性(如血量、速度)
            ApplyDifficultyScaling(enemy, currentWave);

            enemiesAlive++;
            yield return new WaitForSeconds(currentWave.spawnInterval);
        }

        isSpawning = false;
    }

    private void ApplyDifficultyScaling(GameObject enemy, WaveData config)
    {
        EnemyHealth healthComp = enemy.GetComponent<EnemyHealth>();
        if (healthComp != null)
        {
            healthComp.maxHealth *= config.healthMultiplier;
            healthComp.currentHealth = healthComp.maxHealth;
        }

        Rigidbody2D rb = enemy.GetComponent<Rigidbody2D>();
        if (rb != null)
        {
            EnemyAI ai = enemy.GetComponent<EnemyAI>();
            if (ai != null)
                ai.movementSpeed *= config.speedMultiplier;
        }
    }

    public void OnEnemyKilled()
    {
        enemiesAlive--;

        if (enemiesAlive <= 0 && !isSpawning)
        {
            StartCoroutine(WaitAndProceed());
        }
    }

    private IEnumerator WaitAndProceed()
    {
        yield return new WaitForSeconds(postWaveGracePeriod);
        currentWaveIndex++;
        OnAllEnemiesDefeated?.Invoke(currentWaveIndex);
        StartCoroutine(StartNextWave());
    }

    private void HandleGameVictory()
    {
        Debug.Log("恭喜通关!");
        // 触发胜利UI或其他结局逻辑
    }
}

代码逻辑逐行解析:

  • Instance 单例保证全局访问唯一性,配合 DontDestroyOnLoad 支持跨场景存在。
  • spawnPoints 数组允许多入口攻击,打破固定路径,提高战术多样性。
  • UnityEvent<int> 提供事件广播机制,UI或其他模块可订阅波次变更。
  • ApplyDifficultyScaling() 在运行时动态调整敌人属性,无需预设多个变体Prefab。
  • OnEnemyKilled() 被敌人自身调用,通知管理器减员,是状态同步的关键接口。

该脚本已在逻辑层面实现了“启动一波 → 生成敌人 → 监听死亡 → 判定结束 → 进入下一波”的闭环流程。

6.2.2 实现协程(Coroutine)按时间间隔生成敌人

协程是Unity中处理延时操作的理想工具。相比Invoke或固定Update检测,协程语法简洁、可控性强,尤其适合周期性任务。

关键代码片段如下:

yield return new WaitForSeconds(currentWave.spawnInterval);

该语句暂停协程执行指定秒数后再继续,有效模拟“潮水般”陆续登场的效果。若希望更加真实,还可以加入随机抖动:

float jitteredInterval = Random.Range(
    currentWave.spawnInterval * 0.7f,
    currentWave.spawnInterval * 1.3f
);
yield return new WaitForSeconds(jitteredInterval);

此举可打破机械节奏感,让玩家难以预测下一次出现时机,增强心理压迫。

6.2.3 监听敌人死亡事件判断是否进入下一波

为了让 WaveManager 能感知每个敌人的销毁,需在敌人脚本中主动发送通知:

// 在EnemyHealth脚本中
public class EnemyHealth : MonoBehaviour
{
    public int maxHealth = 100;
    private int currentHealth;

    private void Start()
    {
        currentHealth = maxHealth;
    }

    public void TakeDamage(int damage)
    {
        currentHealth -= damage;
        if (currentHealth <= 0)
        {
            Die();
        }
    }

    private void Die()
    {
        // 播放死亡特效、掉落物品等
        DropItems();

        // 通知WaveManager
        WaveManager.Instance?.OnEnemyKilled();

        // 回收或销毁
        ObjectPool.Instance?.ReturnObject(gameObject);
        // 或直接 Destroy(gameObject);
    }

    private void DropItems() { /* 实现补给掉落 */ }
}

此处体现了良好的职责分离:敌人负责“何时死”,管理器负责“死了几个”。两者通过方法调用解耦,符合面向对象设计原则。

方法 作用 注意事项
OnEnemyKilled() 减少计数器 必须确保每次死亡仅调用一次
WaitAndProceed() 延迟跳转下一波 可插入动画、升级界面等中间环节
HandleGameVictory() 终局处理 可替换为Boss战或无限模式
sequenceDiagram
    participant WM as WaveManager
    participant E as Enemy
    participant PO as PoolManager

    WM->>WM: StartCoroutine(StartNextWave)
    loop 生成敌人
        WM->>E: Instantiate(enemyPrefab)
        WM->>E: ApplyDifficultyScaling()
        WM-->>WM: enemiesAlive++
        WM->>WM: wait spawnInterval
    end

    E->>E: 受伤至死亡
    E->>WM: OnEnemyKilled()
    WM-->>WM: enemiesAlive--
    alt 全部消灭?
        WM->>WM: StartCoroutine(WaitAndProceed)
        WM->>WM: currentWaveIndex++
        WM->>WM: 开始下一波
    end

序列图清晰展示了对象间交互顺序,凸显事件驱动机制的有效性。

6.3 敌人AI基础行为编程

仅有波次生成还不够,敌人必须表现出智能追击行为才能构成真正威胁。本节介绍基于 Rigidbody2D 的Seek行为实现,并拓展巡逻与闪避机制。

6.3.1 使用Rigidbody2D朝玩家移动(Seek Behavior)

public class EnemyAI : MonoBehaviour
{
    public float movementSpeed = 3f;
    public Transform playerTarget;
    private Rigidbody2D rb;

    private void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
        if (playerTarget == null)
            playerTarget = GameObject.FindGameObjectWithTag("Player").transform;
    }

    private void FixedUpdate()
    {
        if (playerTarget != null)
        {
            Vector2 direction = (playerTarget.position - transform.position).normalized;
            rb.velocity = direction * movementSpeed;
        }
        else
        {
            rb.velocity = Vector2.zero;
        }
    }
}

参数说明:

  • movementSpeed 控制移动快慢,受WaveData影响;
  • FixedUpdate 与物理系统同步,避免穿墙;
  • normalized 确保方向向量长度为1,防止速度异常放大。

此为基础追踪逻辑,虽简单但高效。为进一步优化,可添加距离阈值判断,进入一定范围后加速冲刺。

6.3.2 添加简单巡逻或闪避行为增加挑战性

为避免敌人行为单调,可引入状态机区分“巡逻”与“追击”:

public enum AIState { Patrol, Chase }

public class PatrolEnemy : MonoBehaviour
{
    public Vector3[] patrolPoints;
    public float reachThreshold = 0.5f;
    private int currentPointIndex = 0;
    public AIState state = AIState.Patrol;

    private void FixedUpdate()
    {
        Vector3 target = patrolPoints[currentPointIndex];
        Vector2 direction = (target - transform.position).normalized;

        if (Vector3.Distance(transform.position, target) < reachThreshold)
        {
            currentPointIndex = (currentPointIndex + 1) % patrolPoints.Length;
        }

        rb.velocity = direction * movementSpeed;
    }
}

巡逻路线可通过Gizmos可视化调试,提升开发效率。

6.3.3 实现死亡后掉落分数或补给品机制

回到 Die() 方法中的 DropItems() 实现:

[Header("掉落配置")]
public GameObject[] dropPrefabs; // 子弹包、护盾、金币等
public float dropChance = 0.3f;

private void DropItems()
{
    if (Random.value <= dropChance)
    {
        GameObject drop = dropPrefabs[Random.Range(0, dropPrefabs.Length)];
        Instantiate(drop, transform.position, Quaternion.identity);
    }
}

结合 WaveManager 中的 scoreValue ,即可实现击杀加分:

ScoreManager.Instance.AddScore(currentWave.scoreValue);

最终形成“生成→追击→死亡→回收+奖励”的完整生态链。

7. 用户界面(UI)系统构建(Canvas、Text、Button、血条与分数显示)

7.1 UI Canvas的渲染模式与适配策略

在Unity中,UI系统的构建始于 Canvas 组件,它是所有UI元素的容器。针对2D射击游戏的需求,正确选择Canvas的渲染模式并实现分辨率自适应是确保UI跨设备一致显示的关键。

7.1.1 Screen Space - Overlay与World Space的选择依据

Unity提供了三种Canvas渲染模式: Screen Space - Overlay Screen Space - Camera World Space 。对于本项目中的HUD类界面(如血条、分数、暂停按钮),推荐使用 Screen Space - Overlay ,因为它直接绘制在屏幕最上层,不依赖主摄像机,性能开销最小,且不受场景物体遮挡影响。

// 示例:通过代码动态设置Canvas渲染模式
Canvas canvas = GetComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;

而如果需要将UI元素绑定到特定游戏对象(如敌人头顶的血条),则应使用 World Space 模式,并将其锚定至目标位置:

// 设置World Space Canvas并绑定到摄像机
canvas.renderMode = RenderMode.WorldSpace;
canvas.worldCamera = Camera.main;
canvas.transform.position = enemyHeadPosition;

7.1.2 使用Canvas Scaler实现多分辨率自适应

为适配不同屏幕尺寸,必须配置 Canvas Scaler 组件。推荐使用 Scale With Screen Size 模式,并设定参考分辨率(如1920×1080),使UI元素按比例缩放。

属性 推荐值 说明
UI Scale Mode Scale With Screen Size 自动缩放UI
Reference Resolution 1920 x 1080 设计时基准分辨率
Screen Match Mode Match Width Or Height 建议设为0.5(宽高平衡)
Physical Unit Pixel 精确控制像素对齐

此外,启用 Pixel Perfect 选项可避免Sprite模糊,尤其适用于像素风2D游戏。

// 强制UI在Update中对齐像素网格(可选优化)
private void Update()
{
    transform.position = Vector3Int.RoundToInt(transform.position);
}

7.2 核心UI元素的设计与绑定

7.2.1 创建HP Bar使用Slider组件实时反映生命值

血条是玩家生存状态的核心反馈。使用Unity内置的 Slider 组件可快速实现。

操作步骤:
1. 在Hierarchy中右键 → UI → Slider。
2. 删除默认的Fill Area子对象中的Background(简化视觉)。
3. 调整Fill Color为红色,表示当前生命值。
4. 将 Value 范围设为0–100,对应角色最大生命值。

绑定脚本示例:

public class HealthBar : MonoBehaviour
{
    [SerializeField] private Slider slider;

    public void SetMaxHealth(int health)
    {
        slider.maxValue = health;
        slider.value = health;
    }

    public void SetHealth(int currentHealth)
    {
        slider.value = currentHealth;
    }
}

调用时机通常在角色受伤事件后触发:

player.OnDamageTaken += () => uiManager.UpdateHealth(player.CurrentHealth);

7.2.2 显示当前波数与得分使用TextMeshPro增强可读性

传统Text组件已逐渐被 TextMeshPro 取代,后者支持高质量字体渲染、阴影、轮廓等效果。

安装方式:
- Window → Package Manager → Install “TextMesh Pro”

创建文本元素:
1. 右键 → UI → Text - TextMeshPro。
2. 输入模板: Wave: {0} | Score: {1}

C#动态更新代码:

public class UIManager : MonoBehaviour
{
    [SerializeField] private TMP_Text waveScoreText;

    public void UpdateWaveAndScore(int wave, int score)
    {
        waveScoreText.text = $"Wave: {wave} | Score: {score:N0}";
    }
}

{0:N0} 表示千位分隔符格式化,提升数字可读性。

7.2.3 添加Pause Button并注册事件回调函数

添加暂停按钮流程如下:
1. 创建Button(UI → Button)。
2. 修改标签为“PAUSE”。
3. 在Inspector中点击“+”号添加OnClick事件。

代码绑定:

public class UIManager : MonoBehaviour
{
    [SerializeField] private GameObject pauseMenuPanel;

    public void SetupPauseButton(Button pauseBtn)
    {
        pauseBtn.onClick.AddListener(TogglePause);
    }

    private void TogglePause()
    {
        bool isPaused = !pauseMenuPanel.activeSelf;
        pauseMenuPanel.SetActive(isPaused);
        Time.timeScale = isPaused ? 0f : 1f; // 暂停物理与时间
    }
}

7.3 游戏状态界面的动态更新机制

7.3.1 编写UIManager脚本监听游戏事件(DamageTaken、ScoreChanged)

采用事件驱动架构解耦逻辑与表现层:

public class UIManager : MonoBehaviour
{
    public static UIManager Instance;

    private void Awake()
    {
        if (Instance == null) Instance = this;
        else Destroy(gameObject);
    }

    private void OnEnable()
    {
        Player.OnDamageTaken += UpdateHealth;
        Enemy.OnEnemyKilled += AddScore;
        WaveManager.OnWaveChanged += UpdateWaveDisplay;
    }

    private void OnDisable()
    {
        Player.OnDamageTaken -= UpdateHealth;
        Enemy.OnEnemyKilled -= AddScore;
        WaveManager.OnWaveChanged -= UpdateWaveDisplay;
    }

    private void UpdateHealth(int current, int max)
    {
        healthBar.SetMaxValue(max);
        healthBar.SetValue(current);
    }

    private void AddScore(int points)
    {
        currentScore += points;
        UpdateScoreText();
    }
}

7.3.2 实现暂停菜单与GameOver界面的激活/隐藏逻辑

定义UI状态枚举:

public enum UIState { Playing, Paused, GameOver }

状态切换逻辑:

public void SetUIState(UIState state)
{
    playingPanel.SetActive(state == UIState.Playing);
    pauseMenuPanel.SetActive(state == UIState.Paused);
    gameOverPanel.SetActive(state == UIState.GameOver);
}

配合GameManager全局调度:

if (Input.GetKeyDown(KeyCode.Escape))
{
    UIManager.Instance.SetUIState(GameManager.IsPaused ? UIState.Playing : UIState.Paused);
}

7.3.3 添加动画过渡效果提升用户体验

使用Animator为菜单添加淡入/滑动动画:

stateDiagram-v2
    [*] --> Hidden
    Hidden --> FadeIn : Show()
    FadeIn --> Visible
    Visible --> FadeOut : Hide()
    FadeOut --> Hidden

动画参数控制:

  • Trigger : Show , Hide
  • Float : Alpha
  • Bool : IsVisible

通过代码播放:

animator.SetTrigger("Show");

结合DOTween等插件可实现更流畅的补间动画:

panelRectTransform.DOLocalMoveX(0, 0.5f).SetEase(Ease.OutBack);

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:Unity 2D射击游戏模板“2D Wave Shooter 1”是一个面向初学者与进阶开发者的完整游戏框架,涵盖从基础操作到核心机制的全面实现。该模板基于Unity引擎和C#脚本语言,提供2D游戏开发的核心组件与逻辑结构,包括角色控制、射击机制、敌人波浪系统、UI界面、音频管理及物理系统等。通过本项目实战,开发者可快速掌握Unity 2D开发流程,理解游戏对象与组件协作机制,并具备独立构建类似射击类游戏的能力。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

标题基于SpringBoot的高校餐饮档口管理系统设计与实现AI更换标题第1章引言介绍高校餐饮档口管理系统的研究背景、意义、国内外现状及论文方法与创新点。1.1研究背景与意义阐述高校餐饮档口管理现状及系统开发的重要性。1.2国内外研究现状分析国内外高校餐饮管理系统的研究与应用进展。1.3研究方法及创新点概述本文采用的研究方法及系统设计的创新之处。第2章相关理论总结与高校餐饮档口管理系统相关的现有理论。2.1SpringBoot框架理论阐述SpringBoot框架的原理、优势及其在Web开发中的应用。2.2数据库设计理论介绍数据库设计的基本原则、方法和步骤。2.3系统安全理论讨论系统安全设计的重要性及常见安全措施。第3章系统需求分析对高校餐饮档口管理系统的功能需求、性能需求等进行详细分析。3.1功能需求分析列举系统需实现的主要功能,如档口管理、订单处理等。3.2性能需求分析分析系统对响应时间、并发处理能力等性能指标的要求。3.3非功能需求分析阐述系统对易用性、可维护性等非功能方面的需求。第4章系统设计详细描述高校餐饮档口管理系统的设计过程。4.1系统架构设计给出系统的整体架构,包括前端、后端和数据库的设计。4.2模块设计详细介绍各个功能模块的设计,如用户管理、档口信息管理等。4.3数据库设计阐述数据库表结构的设计、数据关系及索引优化等。第5章系统实现与测试介绍高校餐饮档口管理系统的实现过程及测试方法。5.1系统实现系统各模块的具体实现过程,包括代码编写和调试。5.2系统测试方法介绍系统测试的方法、测试用例设计及测试环境搭建。5.3系统测试结果与分析从功能、性能等方面对系统测试结果进行详细分析。第6章结论与展望总结本文的研究成果,并展望未来的研究方向。6.1研究结论概括高校餐饮档口管理系统的设计与实现成果。6.2展望指出系统存在的不足及未来改进和扩展的方向。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值