【Unity2D】【5】游戏的骨架,架构与系统设计

Unity 2D 从零精通:第五阶段 - 游戏的骨架,架构与系统设计

引言:从原型到可维护的产品

至此,我们已经拥有了一个视觉上吸引人、关卡设计巧妙的平台游戏世界。然而,如果仔细审视我们的代码,会发现它们像一团随意缠绕的线:玩家脚本直接修改UI文本,子弹脚本直接调用GameManager的静态实例,各个系统紧密耦合。这在小型原型中尚可接受,但随着项目规模扩大,它会变成一场维护噩梦。

在本篇中,我们将从“工匠”迈向“工程师”。我们将不再只关注功能的实现,而更关注如何实现。我们将学习构建游戏的基础架构,使得代码更加模块化、灵活、易于调试和扩展。我们将引入设计模式、事件系统、状态管理,并最终让我们的游戏能够记住玩家的进度。

第一章:重构玩家控制器——实现健壮的移动与跳跃

我们的移动代码目前很简单,但一个平台游戏角色需要更复杂的动作,如跳跃、 Coyote Time(土狼时间)、Jump Buffering(跳跃缓冲)等。我们将重构播放器控制器,使其更加强大和流畅。

1.1 使用物理移动替代Transform.Translate

之前我们使用Transform.Translate来移动玩家,但这会忽略物理引擎。更好的方式是使用Rigidbody2D.velocity来控制移动,这样可以与物理碰撞更好地交互。

  1. 为玩家添加刚体:确保PlayerShip(现在或许应该改名叫Player了)有一个Rigidbody2D。设置Gravity Scale为合适的值(如3),Freeze Rotation Z轴。
  2. 重构移动代码
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 创建交互式暂停菜单
  1. 搭建UI:在Hierarchy中创建UI -> Canvas,然后在其下创建Panel作为背景,并在其中添加TextButton作为菜单选项(如“继续”、“设置”、“退出”)。
  2. 编写暂停管理器
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() { /* 打开设置面板 */ }
}
  1. 绑定事件:在Unity编辑器中,选中按钮,在Inspector的Button组件下方,点击+添加一个OnClick事件,将包含脚本的对象拖入,然后选择对应的方法(如PauseMenuManager.Resume)。
2.2 创建动态血条
  1. 创建血条UI:使用UI -> Slider。将其BackgroundHandle Slide Area/Handle的颜色设置为红色(或删除Handle),将Fill的颜色设置为绿色。将Slider的Value设置为1。
  2. 编写健康系统(挂在玩家身上):
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 使用事件重构计分系统
  1. 修改Bullet脚本(触发者):不再直接调用GameManager.instance.AddScore()
// 在Bullet的OnTriggerEnter2D中
if (collision.gameObject.CompareTag("Planet"))
{
    // 触发加分事件,传递加分数值。谁监听这个事件并处理,我们不用关心。
    GameEvents.TriggerScoreChanged(10);
    Destroy(collision.gameObject);
    Destroy(gameObject);
}
  1. 修改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;
    }
}

现在,BulletScoreUI之间没有任何直接引用。它们只通过GameEvents这个中间人进行通信。这使得代码无比灵活,例如,我们可以很容易地添加一个成就系统来监听得分事件,而完全不需要修改BulletScoreUI的代码。

第四章:数据持久化——存档与读档

让游戏能够记住玩家的进度是至关重要的。

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来读写文件。

  1. 定义数据类
[System.Serializable] // 必须标记为可序列化
public class GameSaveData
{
    public int savedHighScore;
    public int savedCoins;
    public int lastCompletedLevel;
    // ... 任何你想保存的数据
}
  1. 编写保存和加载方法
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(); // 返回一个新的空数据
        }
    }
}
第五章:总结与展望

你在本篇构建了游戏的坚实骨架:

  1. 重构了物理驱动的玩家控制器:实现了更真实移动和高级跳跃机制(Coyote Time, Jump Buffer)。
  2. 创建了动态UI系统:包括交互式暂停菜单和实时更新的血条。
  3. 引入了事件驱动架构:使用C#事件和委托彻底解耦了系统间的通信,使代码无比灵活和可扩展。
  4. 实现了数据持久化:学会了使用PlayerPrefs存储简单数据,以及使用JSON和文件系统存储复杂的游戏存档。

你的项目不再是一个脆弱的原型,而是一个结构清晰、易于维护和扩展的准产品。你学会了如何思考架构,而不仅仅是实现功能。

下一篇预告:《第六阶段:最终打磨——优化、音频与发布》
在系列的最后一篇,我们将为游戏进行最后的抛光:

  • 性能优化:使用Profiler分析性能瓶颈,学习对象池(Object Pooling) 来高效管理子弹等频繁创建销毁的对象。
  • 音频系统:添加背景音乐和音效,大幅提升游戏体验。
  • 最终构建与发布:学习如何配置构建设置(Build Settings),为不同平台(PC, WebGL, 移动端)打包游戏,并分享给你的朋友。

练习与思考:

  1. 尝试使用事件系统来重构暂停功能。让PauseMenuManager触发OnGamePaused事件,让玩家、敌人、动画等系统监听这个事件并做出相应反应(如停止更新、停止动画)。
  2. 为你的存档系统添加加密功能(简单的如XOR运算,复杂的如AES),防止玩家轻易修改存档文件。
  3. 创建一个“设置”菜单,允许玩家调整音量(主音量、音乐音量、音效音量),并将这些设置用PlayerPrefs保存起来。

你已经拥有了构建一个完整、高质量2D游戏所需的所有工具和知识。最后一步,就是将它们打磨至完美。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值