Unity 2D 从零精通:第五阶段 - 游戏的骨架,架构与系统设计
引言:从原型到可维护的产品
至此,我们已经拥有了一个视觉上吸引人、关卡设计巧妙的平台游戏世界。然而,如果仔细审视我们的代码,会发现它们像一团随意缠绕的线:玩家脚本直接修改UI文本,子弹脚本直接调用GameManager的静态实例,各个系统紧密耦合。这在小型原型中尚可接受,但随着项目规模扩大,它会变成一场维护噩梦。
在本篇中,我们将从“工匠”迈向“工程师”。我们将不再只关注功能的实现,而更关注如何实现。我们将学习构建游戏的基础架构,使得代码更加模块化、灵活、易于调试和扩展。我们将引入设计模式、事件系统、状态管理,并最终让我们的游戏能够记住玩家的进度。
第一章:重构玩家控制器——实现健壮的移动与跳跃
我们的移动代码目前很简单,但一个平台游戏角色需要更复杂的动作,如跳跃、 Coyote Time(土狼时间)、Jump Buffering(跳跃缓冲)等。我们将重构播放器控制器,使其更加强大和流畅。
1.1 使用物理移动替代Transform.Translate
之前我们使用Transform.Translate
来移动玩家,但这会忽略物理引擎。更好的方式是使用Rigidbody2D.velocity
来控制移动,这样可以与物理碰撞更好地交互。
- 为玩家添加刚体:确保
PlayerShip
(现在或许应该改名叫Player
了)有一个Rigidbody2D
。设置Gravity Scale
为合适的值(如3
),Freeze Rotation
Z轴。 - 重构移动代码:
public class PlayerController : MonoBehaviour // 重命名脚本以更好地反映其功能
{
public float moveSpeed = 10f;
public float jumpForce = 15f;
public LayerMask groundLayer; // 用于检测什么是地面的LayerMask
public Transform groundCheckPoint; // 用于检测是否在地上的空子物体
public float groundCheckRadius = 0.2f; // 检测半径
private Rigidbody2D rb;
private bool isGrounded;
private float horizontalInput;
void Start()
{
rb = GetComponent<Rigidbody2D>();
}
void Update()
{
// 获取输入
horizontalInput = Input.GetAxisRaw("Horizontal"); // 使用Raw获取瞬时值,更灵敏
// 跳跃输入检测
if (Input.GetButtonDown("Jump") && isGrounded)
{
rb.velocity = new Vector2(rb.velocity.x, jumpForce);
}
}
void FixedUpdate()
{
// 物理计算最好放在FixedUpdate中
// 移动
rb.velocity = new Vector2(horizontalInput * moveSpeed, rb.velocity.y);
// 检测是否在地面上
CheckIfGrounded();
}
void CheckIfGrounded()
{
// 在groundCheckPoint位置创建一个圆形,检测是否与groundLayer层重叠
isGrounded = Physics2D.OverlapCircle(groundCheckPoint.position, groundCheckRadius, groundLayer);
// 可选:将isGrounded参数传递给Animator,用于切换跳跃/落地动画
}
// 在Scene视图中绘制检测范围,便于调试
private void OnDrawGizmosSelected()
{
if (groundCheckPoint == null) return;
Gizmos.color = Color.green;
Gizmos.DrawWireSphere(groundCheckPoint.position, groundCheckRadius);
}
}
1.2 实现高级跳跃机制
- Coyote Time(土狼时间):允许玩家在离开平台后的极短时间内仍然可以起跳,提升手感。
- Jump Buffering(跳跃缓冲):允许玩家在落地前的极短时间内按下跳跃键,角色会在落地的瞬间自动起跳。
public class PlayerController : MonoBehaviour
{
// ... 之前的变量 ...
// 高级跳跃变量
public float coyoteTime = 0.1f; // 土狼时间长度
public float jumpBufferTime = 0.1f; // 跳跃缓冲时间长度
private float coyoteTimeCounter;
private float jumpBufferCounter;
void Update()
{
// Coyote Time 逻辑
if (isGrounded)
{
coyoteTimeCounter = coyoteTime; // 落地重置计数器
}
else
{
coyoteTimeCounter -= Time.deltaTime; // 空中开始倒计时
}
// Jump Buffer 逻辑
if (Input.GetButtonDown("Jump"))
{
jumpBufferCounter = jumpBufferTime; // 按下跳跃键,设置缓冲
}
else
{
jumpBufferCounter -= Time.deltaTime; // 缓冲倒计时
}
// 跳跃条件:缓冲期内,且在土狼时间内
if (jumpBufferCounter > 0f && coyoteTimeCounter > 0f)
{
rb.velocity = new Vector2(rb.velocity.x, jumpForce);
jumpBufferCounter = 0f; // 跳跃后清除缓冲,防止连续触发
}
}
void FixedUpdate()
{
// ... 移动和地面检测 ...
}
}
第二章:构建高级UI系统
一个游戏的UI不仅仅是静态图片,它需要动态响应游戏状态。
2.1 创建交互式暂停菜单
- 搭建UI:在Hierarchy中创建
UI -> Canvas
,然后在其下创建Panel
作为背景,并在其中添加Text
和Button
作为菜单选项(如“继续”、“设置”、“退出”)。 - 编写暂停管理器:
public class PauseMenuManager : MonoBehaviour
{
public static bool GameIsPaused = false;
public GameObject pauseMenuUI; // 引用整个暂停菜单的Panel
void Update()
{
if (Input.GetKeyDown(KeyCode.Escape)) // 通常用ESC键触发暂停
{
if (GameIsPaused)
{
Resume();
}
else
{
Pause();
}
}
}
public void Resume()
{
pauseMenuUI.SetActive(false);
Time.timeScale = 1f; // 恢复游戏时间
GameIsPaused = false;
}
void Pause()
{
pauseMenuUI.SetActive(true);
Time.timeScale = 0f; // 冻结游戏时间
GameIsPaused = true;
}
public void QuitGame()
{
Debug.Log("Quitting game...");
Application.Quit();
// 在Unity编辑器中,Application.Quit()不会生效
}
// 这些方法可以绑定到按钮的OnClick()事件上
public void LoadMenu() { /* 加载主菜单场景 */ }
public void OpenSettings() { /* 打开设置面板 */ }
}
- 绑定事件:在Unity编辑器中,选中按钮,在Inspector的
Button
组件下方,点击+
添加一个OnClick事件,将包含脚本的对象拖入,然后选择对应的方法(如PauseMenuManager.Resume
)。
2.2 创建动态血条
- 创建血条UI:使用
UI -> Slider
。将其Background
和Handle Slide Area/Handle
的颜色设置为红色(或删除Handle),将Fill
的颜色设置为绿色。将Slider的Value
设置为1。 - 编写健康系统(挂在玩家身上):
public class HealthSystem : MonoBehaviour
{
public int maxHealth = 100;
private int currentHealth;
public Slider healthSlider; // 引用UI Slider
void Start()
{
currentHealth = maxHealth;
UpdateHealthUI();
}
public void TakeDamage(int damage)
{
currentHealth -= damage;
currentHealth = Mathf.Clamp(currentHealth, 0, maxHealth);
UpdateHealthUI();
if (currentHealth <= 0)
{
Die();
}
}
void UpdateHealthUI()
{
if (healthSlider != null)
{
healthSlider.value = (float)currentHealth / maxHealth; // 标准化为0-1
}
}
void Die()
{
Debug.Log("Player died!");
// 触发游戏结束逻辑,播放死亡动画等
}
}
第三章:解耦通信——事件系统(Event System)
直接引用(如GameManager.instance
)会导致代码高度耦合。使用基于事件的架构,一个系统可以“广播”一个事件,而其他系统可以“订阅”它,无需知道彼此的存在。
3.1 创建简单的事件管理器
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 首先,定义你项目中可能用到的各种事件委托
public static class GameEvents
{
// 示例:当玩家得分时触发
public static event Action<int> OnScoreChanged;
public static void TriggerScoreChanged(int newScore) => OnScoreChanged?.Invoke(newScore);
// 示例:当玩家生命值变化时触发
public static event Action<int> OnHealthChanged;
public static void TriggerHealthChanged(int newHealth) => OnHealthChanged?.Invoke(newHealth);
// 示例:当游戏暂停状态变化时触发
public static event Action<bool> OnGamePaused;
public static void TriggerGamePaused(bool isPaused) => OnGamePaused?.Invoke(isPaused);
}
3.2 使用事件重构计分系统
- 修改Bullet脚本(触发者):不再直接调用
GameManager.instance.AddScore()
。
// 在Bullet的OnTriggerEnter2D中
if (collision.gameObject.CompareTag("Planet"))
{
// 触发加分事件,传递加分数值。谁监听这个事件并处理,我们不用关心。
GameEvents.TriggerScoreChanged(10);
Destroy(collision.gameObject);
Destroy(gameObject);
}
- 修改UI或其他系统(监听者):创建一个新的脚本
ScoreUI
挂在UI上。
public class ScoreUI : MonoBehaviour
{
public Text scoreText; // 引用UI Text
private int currentScore = 0;
void OnEnable()
{
// 订阅事件
GameEvents.OnScoreChanged += HandleScoreChanged;
}
void OnDisable()
{
// 取消订阅(非常重要!避免内存泄漏和空引用)
GameEvents.OnScoreChanged -= HandleScoreChanged;
}
void HandleScoreChanged(int scoreToAdd)
{
currentScore += scoreToAdd;
scoreText.text = "Score: " + currentScore;
}
}
现在,Bullet
和ScoreUI
之间没有任何直接引用。它们只通过GameEvents
这个中间人进行通信。这使得代码无比灵活,例如,我们可以很容易地添加一个成就系统来监听得分事件,而完全不需要修改Bullet
或ScoreUI
的代码。
第四章:数据持久化——存档与读档
让游戏能够记住玩家的进度是至关重要的。
4.1 使用PlayerPrefs存储简单数据
PlayerPrefs
是Unity提供的用于存储简单键值对的工具,适合存储设置、最高分等。
public class SaveManager : MonoBehaviour
{
private const string HighScoreKey = "HighScore";
public void SaveHighScore(int score)
{
PlayerPrefs.SetInt(HighScoreKey, score);
PlayerPrefs.Save(); // 确保立即写入磁盘
}
public int LoadHighScore()
{
return PlayerPrefs.GetInt(HighScoreKey, 0); // 第二个参数是默认值
}
}
4.2 使用JSON和文件存储复杂数据
对于复杂的游戏数据(如关卡进度、物品栏、玩家属性),PlayerPrefs
并不合适。我们使用JSON
序列化和System.IO
来读写文件。
- 定义数据类:
[System.Serializable] // 必须标记为可序列化
public class GameSaveData
{
public int savedHighScore;
public int savedCoins;
public int lastCompletedLevel;
// ... 任何你想保存的数据
}
- 编写保存和加载方法:
public class SaveManager : MonoBehaviour
{
private string saveFilePath;
void Awake()
{
saveFilePath = Application.persistentDataPath + "/gamesave.sav"; // 路径通常是C:\Users\<User>\AppData\LocalLow\<CompanyName>\<ProductName>
}
public void SaveGame(GameSaveData data)
{
// 将数据对象转换为JSON字符串
string jsonData = JsonUtility.ToJson(data, true); // true参数用于格式化,便于阅读
// 将字符串写入文件
System.IO.File.WriteAllText(saveFilePath, jsonData);
Debug.Log("Game saved to: " + saveFilePath);
}
public GameSaveData LoadGame()
{
if (System.IO.File.Exists(saveFilePath))
{
// 从文件读取JSON字符串
string jsonData = System.IO.File.ReadAllText(saveFilePath);
// 将JSON字符串转换回数据对象
GameSaveData loadedData = JsonUtility.FromJson<GameSaveData>(jsonData);
Debug.Log("Game loaded from: " + saveFilePath);
return loadedData;
}
else
{
Debug.LogWarning("Save file not found. Creating new one.");
return new GameSaveData(); // 返回一个新的空数据
}
}
}
第五章:总结与展望
你在本篇构建了游戏的坚实骨架:
- 重构了物理驱动的玩家控制器:实现了更真实移动和高级跳跃机制(Coyote Time, Jump Buffer)。
- 创建了动态UI系统:包括交互式暂停菜单和实时更新的血条。
- 引入了事件驱动架构:使用C#事件和委托彻底解耦了系统间的通信,使代码无比灵活和可扩展。
- 实现了数据持久化:学会了使用
PlayerPrefs
存储简单数据,以及使用JSON
和文件系统存储复杂的游戏存档。
你的项目不再是一个脆弱的原型,而是一个结构清晰、易于维护和扩展的准产品。你学会了如何思考架构,而不仅仅是实现功能。
下一篇预告:《第六阶段:最终打磨——优化、音频与发布》
在系列的最后一篇,我们将为游戏进行最后的抛光:
- 性能优化:使用Profiler分析性能瓶颈,学习对象池(Object Pooling) 来高效管理子弹等频繁创建销毁的对象。
- 音频系统:添加背景音乐和音效,大幅提升游戏体验。
- 最终构建与发布:学习如何配置构建设置(Build Settings),为不同平台(PC, WebGL, 移动端)打包游戏,并分享给你的朋友。
练习与思考:
- 尝试使用事件系统来重构暂停功能。让
PauseMenuManager
触发OnGamePaused
事件,让玩家、敌人、动画等系统监听这个事件并做出相应反应(如停止更新、停止动画)。 - 为你的存档系统添加加密功能(简单的如XOR运算,复杂的如AES),防止玩家轻易修改存档文件。
- 创建一个“设置”菜单,允许玩家调整音量(主音量、音乐音量、音效音量),并将这些设置用
PlayerPrefs
保存起来。
你已经拥有了构建一个完整、高质量2D游戏所需的所有工具和知识。最后一步,就是将它们打磨至完美。