C# / .NET / Unity 应用开发

本章将系统学习 C# 语言、.NET 平台以及 Unity 引擎,最终完成一个 osu! 风格的音乐节奏点击游戏。内容涵盖从语言基础到游戏架构设计的完整知识链,适合具备 C++ 基础的读者迁移学习。

本页阅读量: 加载中...

C# 语言基础

通俗类比

如果 C++ 像手动挡跑车,需要自己操控每一个细节,那么 C# 就像自动挡轿车,许多繁琐的工作(内存管理、指针操作)由运行时自动处理。你只需专注于驾驶路线(业务逻辑),而不必分心换挡(内存分配)。

C# 与 C++ 的主要差异

特性C++C#
内存管理手动(new/delete)自动垃圾回收(GC)
指针可直接使用unsafe 上下文才能使用
继承支持多重继承仅支持单继承,多接口实现
字符串字符数组,需手动管理string 类型,不可变,自动管理
属性无原生属性,用 getter/setter原生 property 语法
事件函数指针/回调event 关键字 + 委托
命名空间支持支持,且支持 using 别名

基础语法速览

C#
// 程序入口
using System;

class Program {
    static void Main(string[] args) {
        Console.WriteLine("Hello, C#!");
    }
}

// 变量声明(类型推断)
int score = 100;
double pi = 3.14159;
bool isActive = true;
string name = "Player1";
var health = 100;  // 编译器自动推断为 int

// 数组
int[] scores = new int[5];
string[] names = { "Alice", "Bob", "Carol" };

// 列表(动态数组)
using System.Collections.Generic;
List<int> scores = new List<int>();
scores.Add(90);
scores.Add(85);
scores.Remove(90);

// 属性
class Player {
    public string Name { get; set; }
    public int Health {
        get { return health; }
        set { health = Math.Max(0, value); }
    }
    private int health;
}

// 条件与循环(与 C++ 几乎相同)
if (score >= 90) {
    Console.WriteLine("优秀");
} else if (score >= 60) {
    Console.WriteLine("及格");
} else {
    Console.WriteLine("不及格");
}

for (int i = 0; i < 10; i++) {
    Console.WriteLine(i);
}

foreach (var n in names) {  // C# 特有的 foreach
    Console.WriteLine(n);
}

// 函数
static int Add(int a, int b) => a + b;  // 表达式体(lambda 风格)

面向对象核心

C#
// 继承与多态
class Entity {
    public virtual void Update() {  // virtual 允许重写
        Console.WriteLine("Entity Update");
    }
}

class Player : Entity {  // 单继承
    public override void Update() {  // override 重写
        Console.WriteLine("Player Update");
    }
}

// 接口
interface IDamageable {
    void TakeDamage(int amount);
}

class Enemy : Entity, IDamageable {  // 单继承 + 多接口
    public void TakeDamage(int amount) {
        Console.WriteLine($"Enemy took {amount} damage");  // 字符串插值
    }
}

// 委托与事件(Unity 中大量使用)
delegate void OnScoreChanged(int newScore);

class ScoreManager {
    public event OnScoreChanged ScoreChanged;
    private int score;
    public int Score {
        get => score;
        set {
            score = value;
            ScoreChanged?.Invoke(score);  // ?. 空条件运算符
        }
    }
}
提示

C# 的 ?.Invoke() 称为空条件运算符,若委托未绑定任何方法(为 null),则不会调用也不会报错。这在 Unity 的事件系统中非常常用。

编程练习

编写一个 SongNote 类,用于存储 osu! 风格游戏中的音符信息:包含 Time(出现时间,毫秒)、XY(位置坐标)、Type(类型:0=普通圆,1=滑条,2=转盘)属性。实现构造函数,并写一个 ToString() 方法返回格式化的音符信息。

.NET 基础

通俗类比

.NET 就像剧院的后台管理系统。它提供了一套完整的运行环境和标准服务(舞台搭建、灯光控制、音响系统、票务管理)。C# 是台上的演员(编程语言),而 .NET 是支撑演出顺利进行的全套基础设施。没有 .NET,演员没有舞台可以表演;没有 C#,舞台空无一物。

什么是 .NET

.NET 是 Microsoft 开发的跨平台开发框架,提供了:

  • 公共语言运行时(CLR):执行 C# 代码的虚拟机,提供内存管理、异常处理、类型安全
  • 基类库(BCL):文件 I/O、网络通信、集合类、LINQ 等丰富功能
  • 跨平台能力:.NET 程序可在 Windows、macOS、Linux 上运行

常用的 .NET 命名空间

C#
using System;            // 基础类型、Console、Math、DateTime
using System.IO;         // 文件读写:File、StreamReader
using System.Linq;       // LINQ:查询集合的链式语法
using System.Collections.Generic;  // List、Dictionary、Queue
using System.Threading;  // 多线程、异步
using System.Text.Json;  // JSON 序列化
using System.Net.Http;   // HTTP 请求

LINQ 查询(.NET 的强大工具)

C#
// LINQ 用于高效查询和转换集合
var notes = new List<SongNote> {
    new SongNote { Time = 1000, X = 100, Y = 200, Type = 0 },
    new SongNote { Time = 2500, X = 300, Y = 150, Type = 1 },
    new SongNote { Time = 4000, X = 500, Y = 300, Type = 0 },
};

// 筛选时间大于 2000ms 的音符
var lateNotes = notes.Where(n => n.Time > 2000).ToList();

// 按时间排序
var sorted = notes.OrderBy(n => n.Time).ToList();

// 计算平均 X 坐标
double avgX = notes.Average(n => n.X);

// 分组统计各类型数量
var groups = notes.GroupBy(n => n.Type)
                   .Select(g => new { Type = g.Key, Count = g.Count() });

异步编程(async / await)

C#
// 异步加载资源是 Unity 和 .NET 游戏开发的核心技能
using System.IO;
using System.Threading.Tasks;

class BeatmapLoader {
    // async:标记为异步方法
    // Task<T>:返回一个异步任务,最终得到 T 类型结果
    public async Task<string> LoadBeatmapAsync(string path) {
        // await:等待异步操作完成,不阻塞主线程
        using var reader = new StreamReader(path);
        return await reader.ReadToEndAsync();
    }
}

// 调用方式
var loader = new BeatmapLoader();
string content = await loader.LoadBeatmapAsync("song.osu");
说明

Unity 主线程负责渲染和更新游戏逻辑。若在主线程进行文件读取或网络请求,会导致游戏卡顿。async / await 允许在后台执行耗时操作,完成后安全地回到主线程更新游戏状态。

编程练习

创建一个 BeatmapParser 类,实现方法 ParseNotes(string[] lines)。输入是 osu! 谱面文件的音符行数组,每行格式为 "x,y,time,type"(如 "100,200,1500,0")。方法返回 List<SongNote>,并使用 LINQ 筛选出所有类型为 0(普通圆)的音符。

Unity 简介

通俗类比

Unity 就像一个全能电影制片厂。它有摄影棚(场景编辑器)、演员仓库(资源管理)、特效部门(粒子系统)、录音棚(音频系统)、剪辑室(动画系统)和发行渠道(跨平台构建)。你只需把剧本(C# 脚本)交给演员(游戏对象),导演(Unity 引擎)就会自动协调各部门完成整部影片(游戏)。

Unity 的核心优势

  • 跨平台:一次开发,可发布到 Windows、macOS、iOS、Android、Web、主机等 20+ 平台
  • 组件式架构:每个功能都是一个可插拔的组件,灵活组合
  • 强大的资源商店:Asset Store 提供海量免费/付费插件、模型、音效
  • 完善的 2D/3D 支持:2D 精灵、骨骼动画、3D 模型、光照、物理
  • 庞大的社区:文档详尽,社区活跃,问题易找到解决方案

Unity 的授权模式

版本费用适用场景
Personal免费年收入/融资低于 10 万美元的个人或团队
Plus付费订阅需要高级云服务和优先支持的中小团队
Pro付费订阅大型商业项目,需要完整功能和技术支持

编程练习

访问 Unity 官网(unity.com),了解当前最新 LTS(长期支持)版本号,并阅读 Unity 2D 游戏开发入门指南的前三章。记录 Unity Editor 的系统要求(最低配置和推荐配置)。

Unity 安装

安装 Unity Hub

Unity Hub 是管理 Unity 项目和版本的中心工具,必须先安装。

  1. 访问 unity.com/download 下载 Unity Hub
  2. 运行安装程序,按提示完成安装
  3. 打开 Unity Hub,登录 Unity 账号(免费注册)

安装 Unity Editor

  1. 在 Unity Hub 中点击"Installs" → "Install Editor"
  2. 选择一个 LTS(长期支持)版本,如 2022.3 LTS 或 2021.3 LTS
  3. 在模块选择中勾选以下必要模块:
    • Android Build Support(如需发布安卓)
    • Documentation(离线文档)
    • Visual Studio Editor(IDE 集成)
  4. 等待下载和安装完成(约 3-8GB)

配置外部脚本编辑器

Unity 推荐使用 Visual Studio 或 VS Code 编写 C# 脚本:

  1. 安装 Visual Studio Community(免费)或 VS Code
  2. 安装时勾选".NET desktop development"和"Game development with Unity"工作负载
  3. 在 Unity 中:Edit → Preferences → External Tools → External Script Editor → 选择 Visual Studio
提示

LTS 版本代表长期支持,Bug 修复更稳定,适合商业项目。非 LTS 版本功能更新更快,但可能存在未修复的问题。学习阶段建议选择最新的 LTS 版本。

编程练习

完成 Unity Hub 和 Unity Editor 的安装。创建第一个空项目(2D URP 模板),验证项目能正常打开且 Editor 界面加载完整。在 Assets 文件夹中创建一个名为 Scripts 的文件夹。

Unity 界面

主要窗口布局

Unity Editor 默认分为以下几个核心窗口:

窗口功能类比
Scene(场景)可视化编辑游戏世界,拖拽摆放对象电影布景现场
Game(游戏)预览游戏运行效果监视器/取景器
Hierarchy(层级)当前场景中所有对象的列表演员名单
Inspector(检查器)查看和修改选中对象的属性演员档案卡
Project(项目)管理所有资源文件道具仓库
Console(控制台)显示日志、错误和警告导演对讲机

坐标系统

Unity 使用左手坐标系:

  • X 轴:水平方向,向右为正
  • Y 轴:垂直方向,向上为正(2D 游戏中)
  • Z 轴:深度方向,向前为正(3D 中)
说明

在 2D 游戏模式下,Unity 使用 Vector2(X, Y)表示位置。摄像机默认使用正交投影(Orthographic),物体大小不随距离变化,适合 2D 游戏开发。

Prefab(预制体)

Prefab 是可重复使用的对象模板。例如 osu! 中的点击圆圈,创建一个后保存为 Prefab,后续即可批量生成相同配置的圆圈,各自独立修改。

C#
// 在 Hierarchy 中创建一个空对象,命名为 "GameManager"
// 添加 C# 脚本组件,这是 Unity 的标准工作流

using UnityEngine;

public class GameManager : MonoBehaviour {  // 继承 MonoBehaviour 才能成为组件
    // Start 在对象第一次更新前调用(类似构造函数)
    void Start() {
        Debug.Log("游戏开始!");  // 在 Console 窗口输出
    }

    // Update 每帧调用一次(60 FPS = 每秒 60 次)
    void Update() {
        // 每帧执行的逻辑
    }
}

编程练习

在 Unity 中创建一个新场景,完成以下操作:创建一个空 GameObject 命名为 GameManager,创建一个 C# 脚本 GameManager.cs 附加到该对象上。在 Start() 方法中使用 Debug.Log() 输出当前系统时间,在 Update() 中每帧输出帧计数(使用 Time.frameCount),但限制只输出前 10 帧。

游戏对象与组件

通俗类比

游戏对象就像舞台上的演员,本身只是一个空壳(Transform 组件定义位置和大小)。要给演员赋予能力,需要添加组件:添加 Sprite Renderer 让它可见(穿上戏服),添加 AudioSource 让它能发声(戴上麦克风),添加 Rigidbody2D 让它能受物理影响(装上吊威亚)。同一个演员可以不断叠加新的能力。

MonoBehaviour 生命周期

继承 MonoBehaviour 的脚本会按以下顺序自动调用:

C#
using UnityEngine;

public class LifeCycleDemo : MonoBehaviour {
    // 脚本被加载时调用一次(即使对象未激活)
    void Awake() {
        Debug.Log("Awake: 对象初始化");
    }

    // 对象第一次激活时调用(在 Awake 之后)
    void Start() {
        Debug.Log("Start: 游戏开始");
    }

    // 每帧调用(帧率依赖,不适用于物理计算)
    void Update() {
        // Time.deltaTime = 上一帧耗时(秒),用于帧率无关的计算
        float speed = 5f * Time.deltaTime;  // 5单位/秒,而非5单位/帧
    }

    // 固定时间间隔调用(默认 0.02s = 50Hz),适用于物理
    void FixedUpdate() {
        // 物理相关操作放这里
    }

    // 对象被销毁前调用
    void OnDestroy() {
        Debug.Log("OnDestroy: 对象销毁");
    }
}

Transform 组件

Transform 是每个游戏对象必备的基础组件,定义位置和变换:

C#
// 修改位置和旋转
transform.position = new Vector3(10, 20, 0);      // 绝对位置(世界坐标)
transform.localPosition = new Vector3(5, 0, 0);   // 相对父对象的位置
transform.Translate(Vector3.right * Time.deltaTime);  // 每帧向右移动

// 缩放
transform.localScale = new Vector3(2, 2, 1);  // 2倍放大

// 查找子对象和父对象
Transform child = transform.Find("ChildName");
Transform parent = transform.parent;

编程练习

创建一个 CircleSpawner 脚本,在 Start() 中生成 5 个空 GameObject,分别命名为 Circle_1Circle_5,设置它们的位置为 X 轴上间隔 2 个单位的等距排列((0,0,0)、(2,0,0)、(4,0,0)...)。使用 Debug.Log() 输出每个创建的圆圈名称和位置。

2D 精灵系统

通俗类比

精灵(Sprite)就是电影中的角色形象。Sprite Renderer 是投影仪,把角色图片投射到屏幕上。你可以控制图片的大小、颜色、层级(谁在前谁在后)。在 osu! 中,每一个点击圆圈、滑条、背景都是精灵。

Sprite 的基本操作

C#
using UnityEngine;

public class SpriteDemo : MonoBehaviour {
    public SpriteRenderer sr;  // 在 Inspector 中拖拽赋值

    void Start() {
        // 修改颜色(RGBA,0~1)
        sr.color = new Color(1, 0, 0, 0.5f);  // 半透明红色

        // 修改排序层级(越大越靠前)
        sr.sortingOrder = 10;

        // 动态加载精灵
        Sprite loadedSprite = Resources.Load<Sprite>("Textures/Circle");
        sr.sprite = loadedSprite;
    }

    void Update() {
        // 脉冲缩放效果
        float scale = 1 + Mathf.Sin(Time.time * 3) * 0.1f;
        transform.localScale = new Vector3(scale, scale, 1);
    }
}

创建动态精灵对象

C#
using UnityEngine;

public class HitCircle : MonoBehaviour {
    public Sprite circleSprite;  // 在 Inspector 中赋值圆圈图片
    public Sprite approachSprite;  // 判定圈图片

    // 静态方法:工厂模式创建 osu! 风格点击圆圈
    public static HitCircle Create(Vector2 position, float approachTime) {
        GameObject obj = new GameObject("HitCircle");
        obj.transform.position = position;

        // 添加 SpriteRenderer 组件
        SpriteRenderer sr = obj.AddComponent<SpriteRenderer>();
        sr.sortingOrder = 5;

        // 添加自定义脚本
        HitCircle hc = obj.AddComponent<HitCircle>();
        hc.Initialize(approachTime);
        return hc;
    }

    void Initialize(float time) {
        // 初始化逻辑
    }
}
提示

在 Unity 中,public 字段会自动显示在 Inspector 面板中,方便在编辑器中拖拽赋值。若不想暴露给外部但想在 Inspector 中编辑,使用 [SerializeField] private

编程练习

创建 CircleFactory 脚本,实现 SpawnCircle(Vector2 position, Color color) 方法。方法应在指定位置创建一个带有 SpriteRenderer 的 GameObject,设置其精灵为默认圆形(可用纯色纹理),颜色为传入参数,且设置 sortingOrder 使其显示在最上层。在 Start() 中调用此方法在 (0,0)、(2,2)、(-1,3) 三个位置生成不同颜色的圆圈。

输入系统

旧版 Input 系统

Unity 提供两种输入系统。旧版 Input 简单易用,适合入门:

C#
using UnityEngine;

public class InputDemo : MonoBehaviour {
    void Update() {
        // 键盘输入
        if (Input.GetKeyDown(KeyCode.Space)) {  // 按下瞬间(仅一帧)
            Debug.Log("空格按下!");
        }
        if (Input.GetKey(KeyCode.W)) {  // 持续按住
            transform.Translate(Vector3.up * Time.deltaTime);
        }

        // 鼠标输入
        if (Input.GetMouseButtonDown(0)) {  // 0=左键, 1=右键, 2=中键
            Vector3 mousePos = Input.mousePosition;  // 屏幕坐标(像素)
            Vector3 worldPos = Camera.main.ScreenToWorldPoint(mousePos);
            worldPos.z = 0;
            Debug.Log($"点击世界坐标: {worldPos}");
        }

        // osu! 核心:判断鼠标是否在圆圈内
        if (Input.GetMouseButtonDown(0)) {
            Vector3 clickPos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
            float distance = Vector2.Distance(transform.position, clickPos);
            if (distance < 0.5f) {  // 假设圆圈半径为 0.5
                Debug.Log("击中!");
            }
        }
    }
}

新版 Input System(推荐用于复杂项目)

新版 Input System 提供更灵活的配置,支持多设备、组合键、自定义映射。需要在 Package Manager 中安装 Input System 包。

编程练习

编写一个 RhythmInput 脚本,实现 osu! 风格的核心点击判定:在 Update 中检测鼠标左键按下,将点击位置(转换为世界坐标)与当前对象的圆形区域(半径 radius = 0.5f)做碰撞检测。若点击在圆内,输出"Hit!";若在圆外但在半径 1.5f 范围内,输出"Miss!";其他情况不做输出。

音频系统

通俗类比

Unity 的音频系统就像剧场的音响控制台。AudioSource 是舞台上的音箱(可以播放、暂停、调节音量),AudioClip 是音乐母带(音频文件),AudioListener 是观众席的耳朵(通常挂在摄像机上,负责"听到"声音)。在 osu! 中,背景音乐由 AudioSource 循环播放,而每个击中音效是短促的 AudioClip 即时播放。

音频组件基础

C#
using UnityEngine;

public class AudioManager : MonoBehaviour {
    public AudioSource bgmSource;      // 背景音乐播放器
    public AudioSource sfxSource;      // 音效播放器
    public AudioClip bgmClip;          // 背景音乐文件
    public AudioClip hitClip;          // 击中音效
    public AudioClip missClip;         // 失误音效

    void Start() {
        // 播放背景音乐
        bgmSource.clip = bgmClip;
        bgmSource.loop = true;
        bgmSource.volume = 0.7f;
        bgmSource.Play();
    }

    // 播放击中音效
    public void PlayHit() {
        sfxSource.PlayOneShot(hitClip, 1.0f);  // 不中断其他音效
    }

    // osu! 核心:获取当前播放进度(毫秒)
    public float GetCurrentTimeMs() {
        return bgmSource.time * 1000f;
    }

    // 从指定时间开始播放(用于测试和重开)
    public void SeekTo(float timeMs) {
        bgmSource.time = timeMs / 1000f;
    }
}

音频同步与节拍计算

osu! 类节奏游戏的核心是音频时间同步。所有音符的出现时间都与音乐的 BPM(每分钟节拍数)对齐:

C#
using UnityEngine;

public class BeatSync : MonoBehaviour {
    public AudioSource music;
    public float bpm = 120;  // 每分钟 120 拍

    private float beatInterval;  // 每拍间隔(秒)
    private float nextBeatTime;

    void Start() {
        beatInterval = 60f / bpm;  // 120 BPM = 0.5 秒/拍
        nextBeatTime = 0;
    }

    void Update() {
        float currentTime = music.time;

        // 检测是否到达下一拍
        if (currentTime >= nextBeatTime) {
            OnBeat();
            nextBeatTime += beatInterval;
        }
    }

    void OnBeat() {
        // 每拍触发的事件:生成音符、闪烁背景等
        Debug.Log("Beat!");
    }
}

编程练习

创建 Metronome 脚本,在场景中创建一个 Sprite 方块作为节拍指示器。按照 140 BPM 的节拍,每拍让方块在 X 轴上左右摆动一次(使用 Mathf.PingPong 或 Sin 函数),且方块颜色在每拍瞬间变为黄色,然后渐变为白色。音频节拍与视觉反馈必须同步。

UI 系统

通俗类比

Unity 的 UI 系统就像电影的字幕和片头片尾。Canvas 是银幕本身,所有 UI 元素都贴在上面。TextMeshPro 是高质量字体渲染器(比旧版 Text 更清晰),Image 是贴图,Slider 是进度条。在 osu! 中,分数、连击数、生命值条、判定结果(Perfect/Great/Good/Miss)全部通过 UI 系统呈现。

Canvas 与 UI 元素

C#
using UnityEngine;
using TMPro;  // TextMeshPro 命名空间
using UnityEngine.UI;

public class UIManager : MonoBehaviour {
    public TextMeshProUGUI scoreText;   // 分数显示
    public TextMeshProUGUI comboText;   // 连击数显示
    public TextMeshProUGUI judgeText;   // 判定结果(Perfect/Great...)
    public Slider hpSlider;             // 生命值条
    public Image judgeImage;            // 判定结果背景图

    private int score = 0;
    private int combo = 0;

    // 更新分数和连击
    public void AddScore(int points, string judgement) {
        score += points;
        combo++;
        scoreText.text = $"Score: {score:D8}";  // 8位数字,前导补零
        comboText.text = $"{combo}x";

        // 显示判定结果
        judgeText.text = judgement;
        judgeText.color = GetJudgeColor(judgement);

        // 动画效果:判定文字放大后消失
        StopAllCoroutines();
        StartCoroutine(AnimateJudgeText());
    }

    private Color GetJudgeColor(string judge) {
        switch (judge) {
            case "Perfect":  return new Color(1, 0.8f, 0.2f);   // 金色
            case "Great":    return Color.green;
            case "Good":     return Color.yellow;
            case "Miss":     return Color.red;
            default:        return Color.white;
        }
    }

    // 协程:判定文字动画
    System.Collections.IEnumerator AnimateJudgeText() {
        judgeText.transform.localScale = Vector3.one * 1.5f;
        yield return new WaitForSeconds(0.1f);
        judgeText.transform.localScale = Vector3.one;
        yield return new WaitForSeconds(0.5f);
        judgeText.text = "";
    }

    // 更新生命值(0~1)
    public void SetHP(float hp) {
        hpSlider.value = Mathf.Clamp01(hp);
    }
}
提示

TextMeshPro 需要在 Package Manager 中安装(Window → Package Manager → 搜索 TextMeshPro)。它提供了更清晰的字体渲染、轮廓、阴影等效果,是 Unity UI 的标准选择。

编程练习

创建一个 ScoreBoard 脚本,管理一个 TextMeshProUGUI 分数文本和一个 Slider 生命值条。实现 OnHit(int points) 方法增加分数并更新显示(格式为 8 位数字,如 00000150),实现 OnMiss() 方法扣除 2% 生命值并更新 Slider。生命值降至 0 时,将分数文本颜色变为红色并显示 "GAME OVER"。

动画与特效

Tween 动画(代码驱动)

osu! 中的判定圈缩小、击中时的缩放脉冲、连击数跳动等效果,通常用代码插值(Tween)实现,而非 Animator:

C#
using UnityEngine;

public class ApproachCircle : MonoBehaviour {
    public float approachTime = 1.0f;   // 判定圈收缩时间
    public float startScale = 3.0f;     // 起始大小(相对于目标)

    private float timer = 0;
    private Vector3 targetScale;

    void Start() {
        targetScale = transform.localScale;
        transform.localScale = targetScale * startScale;
    }

    void Update() {
        timer += Time.deltaTime;
        float t = timer / approachTime;

        // Lerp 线性插值:从 startScale 平滑过渡到 1.0
        float current = Mathf.Lerp(startScale, 1.0f, t);
        transform.localScale = targetScale * current;

        // 时间到后销毁
        if (t >= 1.0f) {
            OnMiss();
        }
    }

    void OnMiss() {
        Debug.Log("Miss!");
        Destroy(gameObject);
    }

    // osu! 核心:获取当前判定窗口(越接近1.0,判定越严格)
    public float GetAccuracy() {
        return Mathf.Abs(timer / approachTime - 1.0f);
    }
}

粒子系统(击中特效)

osu! 击中圆圈时的爆炸效果可用 Unity Particle System 实现:

C#
using UnityEngine;

public class HitEffect : MonoBehaviour {
    public ParticleSystem hitParticles;   // 在 Inspector 中赋值

    public void Play(Vector3 position, Color color) {
        // 移动到击中位置
        transform.position = position;

        // 修改粒子颜色
        var main = hitParticles.main;
        main.startColor = color;

        // 播放一次
        hitParticles.Play();

        // 2秒后销毁特效对象
        Destroy(gameObject, 2f);
    }
}

编程练习

编写 PulseEffect 脚本,实现一个脉冲缩放动画:对象从正常大小(1.0)缩放到 1.5 倍再恢复到 1.0,总耗时 0.3 秒。使用 Mathf.SmoothStep 替代 Mathf.Lerp 实现更自然的缓动效果。动画完成后自动销毁自身。

物理与碰撞

2D 碰撞检测基础

osu! 本质上是点(鼠标点击)与圆(判定区域)的碰撞检测,不需要复杂的物理引擎,只需距离计算:

C#
using UnityEngine;

public class CircleHitArea : MonoBehaviour {
    public float radius = 0.5f;
    public LayerMask hitLayer;  // 筛选可点击的层级

    // 方法1:直接距离计算(osu! 核心判定)
    public bool IsPointInside(Vector2 point) {
        return Vector2.Distance(transform.position, point) <= radius;
    }

    // 方法2:使用 Physics2D.OverlapCircle(适合复杂场景)
    public Collider2D[] GetOverlapping() {
        return Physics2D.OverlapCircleAll(transform.position, radius, hitLayer);
    }

    // 可视化判定区域(Scene 视图中可见)
    void OnDrawGizmos() {
        Gizmos.color = Color.cyan;
        Gizmos.DrawWireSphere(transform.position, radius);
    }
}

// 射线检测(从摄像机到鼠标位置)
public class RaycastInput : MonoBehaviour {
    void Update() {
        if (Input.GetMouseButtonDown(0)) {
            Vector2 mousePos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
            RaycastHit2D hit = Physics2D.Raycast(mousePos, Vector2.zero);
            if (hit.collider != null) {
                Debug.Log($"点击了: {hit.collider.name}");
            }
        }
    }
}

osu! 判定系统实现

C#
using UnityEngine;

public enum Judgement { Perfect, Great, Good, Miss }

public class HitJudge {
    // 判定窗口(毫秒):点击时间与目标时间的差值
    public static readonly float PERFECT_WINDOW = 50f;
    public static readonly float GREAT_WINDOW = 100f;
    public static readonly float GOOD_WINDOW = 150f;

    public static Judgement Judge(float timeDiffMs) {
        float abs = Mathf.Abs(timeDiffMs);
        if (abs <= PERFECT_WINDOW) return Judgement.Perfect;
        if (abs <= GREAT_WINDOW)   return Judgement.Great;
        if (abs <= GOOD_WINDOW)    return Judgement.Good;
        return Judgement.Miss;
    }

    public static int GetScore(Judgement j, int combo) {
        switch (j) {
            case Judgement.Perfect: return 300 + combo * 10;
            case Judgement.Great:   return 100 + combo * 5;
            case Judgement.Good:    return 50;
            default:               return 0;
        }
    }
}

编程练习

编写 HitTester 脚本,在场景中放置三个不同半径(0.3、0.5、1.0)的圆形区域。在 Update 中检测鼠标点击,计算点击位置到每个圆心的距离,输出击中的是哪个圆以及距离值。若点击位置同时落入多个圆内,优先判定半径最小的圆。

数据持久化

PlayerPrefs(简单键值存储)

C#
using UnityEngine;

public class PlayerData : MonoBehaviour {
    // 保存最高分
    public static void SaveHighScore(string songName, int score) {
        int current = PlayerPrefs.GetInt($"HighScore_{songName}", 0);
        if (score > current) {
            PlayerPrefs.SetInt($"HighScore_{songName}", score);
            PlayerPrefs.Save();  // 立即写入磁盘
        }
    }

    // 读取最高分
    public static int LoadHighScore(string songName) {
        return PlayerPrefs.GetInt($"HighScore_{songName}", 0);
    }
}

JSON 序列化(复杂数据结构)

C#
using UnityEngine;
using System.IO;

// 可序列化的分数记录
[System.Serializable]
public class ScoreRecord {
    public string songName;
    public int score;
    public float accuracy;
    public string rank;  // S/A/B/C/D
    public string date;
}

[System.Serializable]
public class ScoreDatabase {
    public ScoreRecord[] records;
}

public class JsonSaver : MonoBehaviour {
    private string SavePath => Path.Combine(Application.persistentDataPath, "scores.json");

    public void SaveScores(ScoreRecord[] records) {
        var db = new ScoreDatabase { records = records };
        string json = JsonUtility.ToJson(db, true);  // true = 格式化缩进
        File.WriteAllText(SavePath, json);
    }

    public ScoreRecord[] LoadScores() {
        if (!File.Exists(SavePath)) return new ScoreRecord[0];
        string json = File.ReadAllText(SavePath);
        var db = JsonUtility.FromJson<ScoreDatabase>(json);
        return db?.records ?? new ScoreRecord[0];
    }
}

编程练习

创建 SettingsManager 脚本,使用 PlayerPrefs 保存和读取以下游戏设置:主音量(0.0~1.0,float)、判定偏移(毫秒,int)、是否显示判定文字(bool,0/1)。实现 Save()Load() 方法,并在 Start 中自动加载上次保存的设置。

osu! 风格节奏游戏项目

项目概述

本项目将实现一个完整的 osu! 风格音乐节奏点击游戏,包含以下核心功能:

  • 谱面解析:从简化格式加载音符数据(时间、位置、类型)
  • 音符生成:按时间轴动态生成点击圆圈和判定圈
  • 判定系统:Perfect / Great / Good / Miss 四级判定,含时间窗口
  • 分数与连击:基础分 + 连击加成,Miss 打断连击
  • 生命值:击中增加,Miss 扣除,归零游戏结束
  • UI 反馈:实时分数、连击数、判定结果动画、生命条
  • 结算画面:总分、准确率、评级(SS/S/A/B/C/D)

游戏架构设计

采用经典的单例(Singleton)管理器模式,各模块职责分离:

类名职责
GameManager单例,协调游戏状态(Menu/Playing/Paused/Result)
BeatmapLoader解析谱面文件,生成 List<NoteData>
NoteSpawner按音频时间从对象池生成音符对象
HitCircle单个音符逻辑:判定圈收缩、点击判定、销毁
InputHandler检测鼠标点击,将点击事件分发给音符
ScoreManager计算分数、连击、准确率
UIManager更新所有 UI 元素的显示
AudioManager背景音乐播放、时间同步、音效触发

谱面数据结构

C#
using UnityEngine;
using System.Collections.Generic;

// 单个音符数据
[System.Serializable]
public class NoteData {
    public float time;      // 目标时间(毫秒,从歌曲开始)
    public float x, y;     // 屏幕坐标(0~1 归一化,映射到实际分辨率)
    public int type;        // 0=普通圆, 1=滑条, 2=转盘(本项目先实现 0)
}

// 完整谱面
[System.Serializable]
public class Beatmap {
    public string title;
    public string artist;
    public float bpm;
    public List<NoteData> notes = new List<NoteData>();
}

// 谱面加载器(从 JSON 文件加载)
public class BeatmapLoader : MonoBehaviour {
    public TextAsset beatmapJson;  // 在 Inspector 中拖拽 .json 文件

    public Beatmap Load() {
        return JsonUtility.FromJson<Beatmap>(beatmapJson.text);
    }

    // 创建测试谱面的辅助方法
    public static Beatmap CreateTestBeatmap() {
        var bm = new Beatmap {
            title = "Test Song",
            artist = "Test Artist",
            bpm = 120
        };
        // 生成 20 个均匀分布的音符
        for (int i = 0; i < 20; i++) {
            bm.notes.Add(new NoteData {
                time = 2000 + i * 500,   // 从 2 秒开始,每 0.5 秒一个
                x = Random.Range(0.1f, 0.9f),  // 归一化坐标
                y = Random.Range(0.1f, 0.9f),
                type = 0
            });
        }
        return bm;
    }
}

坐标映射系统

osu! 使用 512x384 的虚拟坐标系统,需要映射到 Unity 的实际屏幕像素:

C#
using UnityEngine;

public static class CoordinateMapper {
    // osu! 标准虚拟分辨率
    public const float OSU_WIDTH = 512f;
    public const float OSU_HEIGHT = 384f;

    // 将 osu! 坐标 (0~512, 0~384) 映射到世界坐标
    public static Vector2 ToWorldPosition(float osuX, float osuY) {
        // 假设摄像机正交尺寸和宽高比适配 osu! 比例
        Camera cam = Camera.main;
        float orthoHeight = cam.orthographicSize * 2;
        float orthoWidth = orthoHeight * cam.aspect;

        float worldX = (osuX / OSU_WIDTH - 0.5f) * orthoWidth;
        float worldY = (osuY / OSU_HEIGHT - 0.5f) * orthoHeight;
        return new Vector2(worldX, worldY);
    }
}

Hit Circle 完整实现

C#
using UnityEngine;
using System;

public class HitCircle : MonoBehaviour {
    public NoteData data;              // 音符数据
    public float approachTime = 800f;  // 判定圈提前出现的时间(毫秒)
    public float circleRadius = 0.3f;  // 圆圈半径(世界单位)

    // 组件引用
    private SpriteRenderer circleRenderer;
    private SpriteRenderer approachRenderer;
    private SpriteRenderer overlayRenderer;

    // 状态
    private bool isHit = false;
    private bool isMissed = false;
    private float spawnTime;         // 实际生成时间(毫秒)

    // 事件:击中或错过时通知 GameManager
    public event Action<HitCircle, Judgement, float> OnJudged;

    public void Initialize(NoteData note, float currentTimeMs) {
        data = note;
        spawnTime = currentTimeMs;

        // 设置位置
        Vector2 pos = CoordinateMapper.ToWorldPosition(note.x, note.y);
        transform.position = pos;

        // 获取组件(假设子对象已配置好)
        circleRenderer = transform.Find("Circle").GetComponent<SpriteRenderer>();
        approachRenderer = transform.Find("ApproachCircle").GetComponent<SpriteRenderer>();
        overlayRenderer = transform.Find("Overlay")?.GetComponent<SpriteRenderer>();
    }

    void Update() {
        if (isHit || isMissed) return;

        float currentTime = AudioManager.Instance.GetCurrentTimeMs();
        float timeUntilHit = data.time - currentTime;

        // 判定圈收缩动画
        if (approachRenderer != null) {
            float progress = 1 - (timeUntilHit / approachTime);
            progress = Mathf.Clamp01(progress);
            float scale = Mathf.Lerp(4f, 1f, progress);
            approachRenderer.transform.localScale = Vector3.one * scale;
            approachRenderer.color = new Color(1, 1, 1, 1 - progress);
        }

        // 错过判定:超过 GOOD_WINDOW 仍未点击
        if (timeUntilHit < -HitJudge.GOOD_WINDOW) {
            Miss();
        }
    }

    // 外部调用:检测是否被点击
    public bool TryHit(Vector2 clickPos, float currentTimeMs) {
        if (isHit || isMissed) return false;

        // 距离判定
        float distance = Vector2.Distance(transform.position, clickPos);
        if (distance > circleRadius * 1.5f) return false;

        // 时间判定
        float timeDiff = currentTimeMs - data.time;
        Judgement judge = HitJudge.Judge(timeDiff);

        if (judge != Judgement.Miss) {
            Hit(judge, timeDiff);
            return true;
        }
        return false;
    }

    private void Hit(Judgement judge, float timeDiff) {
        isHit = true;
        OnJudged?.Invoke(this, judge, timeDiff);

        // 击中动画:缩放脉冲后消失
        StartCoroutine(HitAnimation());
    }

    private void Miss() {
        isMissed = true;
        OnJudged?.Invoke(this, Judgement.Miss, 0);
        Destroy(gameObject, 0.3f);
    }

    System.Collections.IEnumerator HitAnimation() {
        // 快速放大
        transform.localScale = Vector3.one * 1.3f;
        circleRenderer.color = Color.white;
        yield return new WaitForSeconds(0.05f);

        // 渐隐
        float elapsed = 0;
        while (elapsed < 0.2f) {
            elapsed += Time.deltaTime;
            float alpha = 1 - (elapsed / 0.2f);
            circleRenderer.color = new Color(1, 1, 1, alpha);
            yield return null;
        }
        Destroy(gameObject);
    }
}

Note Spawner(音符生成器)

C#
using UnityEngine;
using System.Collections.Generic;

public class NoteSpawner : MonoBehaviour {
    public GameObject hitCirclePrefab;   // 在 Inspector 中赋值 Prefab
    public float approachTime = 800f;  // 判定圈提前出现的时间(毫秒)

    private Beatmap beatmap;
    private int nextNoteIndex = 0;
    private List<HitCircle> activeNotes = new List<HitCircle>();

    public void LoadBeatmap(Beatmap bm) {
        beatmap = bm;
        nextNoteIndex = 0;
        activeNotes.Clear();
    }

    void Update() {
        if (beatmap == null || nextNoteIndex >= beatmap.notes.Count) return;

        float currentTime = AudioManager.Instance.GetCurrentTimeMs();

        // 当音频时间到达 spawnTime 时生成音符
        // spawnTime = note.time - approachTime
        while (nextNoteIndex < beatmap.notes.Count) {
            NoteData note = beatmap.notes[nextNoteIndex];
            float spawnTime = note.time - approachTime;

            if (currentTime >= spawnTime) {
                SpawnNote(note);
                nextNoteIndex++;
            } else {
                break;
            }
        }
    }

    void SpawnNote(NoteData note) {
        GameObject obj = Instantiate(hitCirclePrefab, transform);
        HitCircle hc = obj.GetComponent<HitCircle>();
        hc.Initialize(note, AudioManager.Instance.GetCurrentTimeMs());
        hc.OnJudged += OnNoteJudged;
        activeNotes.Add(hc);
    }

    void OnNoteJudged(HitCircle note, Judgement judge, float diff) {
        activeNotes.Remove(note);
        ScoreManager.Instance.ProcessHit(judge);
        UIManager.Instance.ShowJudgement(judge);
    }

    // 将点击事件传递给所有活跃的音符
    public void ProcessClick(Vector2 worldPos) {
        float currentTime = AudioManager.Instance.GetCurrentTimeMs();

        // 优先判定最早出现的音符(避免后面的遮挡前面的)
        for (int i = 0; i < activeNotes.Count; i++) {
            if (activeNotes[i].TryHit(worldPos, currentTime)) {
                break;  // 一次点击只判定一个音符
            }
        }
    }
}

GameManager(游戏总控)

C#
using UnityEngine;
using UnityEngine.SceneManagement;

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

    public enum GameState { Menu, Playing, Paused, GameOver, Result }
    public GameState CurrentState { get; private set; } = GameState.Menu;

    // 引用
    public AudioManager audioManager;
    public NoteSpawner noteSpawner;
    public ScoreManager scoreManager;
    public UIManager uiManager;

    void Awake() {
        if (Instance != null) { Destroy(gameObject); return; }
        Instance = this;
        DontDestroyOnLoad(gameObject);  // 切换场景时不销毁
    }

    public void StartGame(Beatmap beatmap) {
        CurrentState = GameState.Playing;
        scoreManager.Reset();
        noteSpawner.LoadBeatmap(beatmap);
        audioManager.Play(beatmap.title);
        uiManager.ShowGameUI();
    }

    public void PauseGame() {
        if (CurrentState == GameState.Playing) {
            CurrentState = GameState.Paused;
            audioManager.Pause();
            Time.timeScale = 0;  // 冻结游戏时间
            uiManager.ShowPauseMenu();
        }
    }

    public void ResumeGame() {
        CurrentState = GameState.Playing;
        audioManager.Resume();
        Time.timeScale = 1;
        uiManager.HidePauseMenu();
    }

    public void GameOver() {
        CurrentState = GameState.GameOver;
        audioManager.Stop();
        uiManager.ShowGameOver();
    }

    public void ShowResult() {
        CurrentState = GameState.Result;
        var result = scoreManager.GetFinalResult();
        uiManager.ShowResultScreen(result);
    }

    public void ReturnToMenu() {
        Time.timeScale = 1;
        SceneManager.LoadScene("MenuScene");
    }
}

ScoreManager(分数系统)

C#
using UnityEngine;

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

    public int Score { get; private set; }
    public int Combo { get; private set; }
    public int MaxCombo { get; private set; }
    public float HP { get; private set; } = 1f;
    public int TotalNotes { get; set; }
    public int PerfectCount { get; private set; }
    public int GreatCount { get; private set; }
    public int GoodCount { get; private set; }
    public int MissCount { get; private set; }

    void Awake() { Instance = this; }

    public void Reset() {
        Score = 0; Combo = 0; MaxCombo = 0; HP = 1f;
        PerfectCount = GreatCount = GoodCount = MissCount = 0;
    }

    public void ProcessHit(Judgement judge) {
        int points = HitJudge.GetScore(judge, Combo);
        Score += points;

        switch (judge) {
            case Judgement.Perfect:
                Combo++; PerfectCount++;
                HP = Mathf.Min(1f, HP + 0.02f);
                break;
            case Judgement.Great:
                Combo++; GreatCount++;
                HP = Mathf.Min(1f, HP + 0.01f);
                break;
            case Judgement.Good:
                Combo = 0; GoodCount++;
                HP = Mathf.Max(0, HP - 0.05f);
                break;
            case Judgement.Miss:
                Combo = 0; MissCount++;
                HP = Mathf.Max(0, HP - 0.15f);
                break;
        }

        if (Combo > MaxCombo) MaxCombo = Combo;

        // 通知 UI 更新
        UIManager.Instance.UpdateScore(Score, Combo, HP);

        // 生命值归零
        if (HP <= 0) {
            GameManager.Instance.GameOver();
        }
    }

    // 计算最终评级
    public string GetRank() {
        float accuracy = GetAccuracy();
        if (accuracy >= 1.0f && MissCount == 0) return "SS";
        if (accuracy >= 0.95f) return "S";
        if (accuracy >= 0.90f) return "A";
        if (accuracy >= 0.80f) return "B";
        if (accuracy >= 0.60f) return "C";
        return "D";
    }

    public float GetAccuracy() {
        if (TotalNotes == 0) return 0;
        float weighted = PerfectCount * 1.0f + GreatCount * 0.5f + GoodCount * 0.25f;
        return weighted / TotalNotes;
    }

    public GameResult GetFinalResult() {
        return new GameResult {
            score = Score,
            maxCombo = MaxCombo,
            accuracy = GetAccuracy(),
            rank = GetRank(),
            perfect = PerfectCount,
            great = GreatCount,
            good = GoodCount,
            miss = MissCount
        };
    }
}

[System.Serializable]
public class GameResult {
    public int score;
    public int maxCombo;
    public float accuracy;
    public string rank;
    public int perfect, great, good, miss;
}

场景管理与结算画面

C#
// ResultScreen.cs - 结算画面
using UnityEngine;
using TMPro;

public class ResultScreen : MonoBehaviour {
    public TextMeshProUGUI scoreText;
    public TextMeshProUGUI accuracyText;
    public TextMeshProUGUI comboText;
    public TextMeshProUGUI rankText;
    public TextMeshProUGUI detailText;

    public void Display(GameResult result) {
        scoreText.text = $"Score: {result.score:D8}";
        accuracyText.text = $"Accuracy: {result.accuracy:P2}";
        comboText.text = $"Max Combo: {result.maxCombo}x";
        rankText.text = result.rank;
        rankText.color = GetRankColor(result.rank);
        detailText.text = $"Perfect: {result.perfect}  Great: {result.great}  Good: {result.good}  Miss: {result.miss}";
    }

    Color GetRankColor(string rank) {
        switch (rank) {
            case "SS": return new Color(1, 0.84f, 0);     // 金色
            case "S":  return new Color(1, 0.65f, 0);     // 橙色
            case "A":  return Color.green;
            case "B":  return Color.cyan;
            case "C":  return Color.yellow;
            default:   return Color.red;
        }
    }
}

项目设置清单

按照以下步骤在 Unity 中搭建完整项目:

  1. 创建场景:"MenuScene"、"GameScene"、"ResultScene"
  2. 摄像机设置:Projection = Orthographic,Size = 5.4(适配 16:9)
  3. 创建 HitCircle Prefab
    • 根对象:添加 HitCircle.cs
    • 子对象 "Circle":SpriteRenderer,圆形 Sprite,SortingOrder=5
    • 子对象 "ApproachCircle":SpriteRenderer,空心圆 Sprite,SortingOrder=4
    • 子对象 "Overlay":SpriteRenderer,数字纹理,SortingOrder=6
  4. Canvas 设置:Render Mode = Screen Space - Overlay,Scaler = Scale With Screen Size(1920x1080)
  5. AudioManager 对象:两个 AudioSource 组件(BGM + SFX)
  6. 导入资源:背景音乐、击中音效、Miss 音效、圆形纹理、判定圈纹理

完整 Input 处理流程

C#
using UnityEngine;

public class InputHandler : MonoBehaviour {
    void Update() {
        // 鼠标点击
        if (Input.GetMouseButtonDown(0)) {
            Vector3 worldPos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
            worldPos.z = 0;
            GameManager.Instance.noteSpawner.ProcessClick(worldPos);
        }

        // 键盘快捷键
        if (Input.GetKeyDown(KeyCode.Escape)) {
            if (GameManager.Instance.CurrentState == GameManager.GameState.Playing) {
                GameManager.Instance.PauseGame();
            } else if (GameManager.Instance.CurrentState == GameManager.GameState.Paused) {
                GameManager.Instance.ResumeGame();
            }
        }

        // 游戏结束后按空格返回菜单
        if (Input.GetKeyDown(KeyCode.Space)) {
            if (GameManager.Instance.CurrentState == GameManager.GameState.Result
             || GameManager.Instance.CurrentState == GameManager.GameState.GameOver) {
                GameManager.Instance.ReturnToMenu();
            }
        }
    }
}
提示

完成基础版本后,可逐步扩展以下功能:滑条(Slider)支持(按住拖动)、转盘(Spinner)支持、多难度谱面、排行榜系统、更多视觉反馈(连击数放大、背景暗化)、自定义皮肤系统、回放功能。这些是 osu! 完整版的功能,但在本教程基础版本之上,每一个都可以独立实现。

编程练习

在 Unity 中按照本教程的架构搭建一个最小可运行的 osu! 风格节奏游戏原型。要求:1)使用 BeatmapLoader.CreateTestBeatmap() 生成测试谱面;2)实现至少 5 个音符的点击判定;3)判定结果正确显示在 UI 上;4)背景音乐与音符出现时间同步。录制一段运行视频或截图作为完成凭证。

高性能与低延迟优化

通俗类比

开发节奏游戏就像经营一家精密钟表店。普通手表每天误差几秒可以接受,但竞赛级计时器必须精确到毫秒。Unity 默认配置就像普通手表,适合大多数游戏;而节奏游戏需要改装为竞赛级——每一个齿轮(渲染管线、音频缓冲、输入轮询)都必须调整到最低延迟状态。

音频延迟的来源与对策

在节奏游戏中,音频延迟是最致命的敌人。玩家听到的节拍与实际判定时刻的偏差,直接毁掉游戏体验。延迟主要来自三个层面:

延迟来源典型值解决方案
操作系统音频缓冲50~200ms使用低延迟音频 API(ASIO、WASAPI Exclusive)
Unity AudioSource 缓冲20~40ms设置 AudioSettings.dspBufferSize 为最小值
扬声器/耳机硬件延迟10~50ms选用有线低延迟耳机,避免蓝牙耳机
脚本更新时间差0~16ms使用 AudioSettings.dspTime 而非 Time.time

Unity 音频低延迟配置

C#
using UnityEngine;

public class LowLatencyAudio : MonoBehaviour {
    void Awake() {
        // 获取当前 DSP 缓冲区大小
        int bufferSize, numBuffers;
        AudioSettings.GetDSPBufferSize(out bufferSize, out numBuffers);
        Debug.Log($"当前缓冲区: {bufferSize} 样本, {numBuffers} 缓冲");

        // 设置为最小配置(增加 CPU 负载但降低延迟)
        AudioSettings.SetDSPBufferSize(256, 2);  // 256 样本 / 2 缓冲
        // 采样率 48000Hz 时,256 样本 = 5.3ms 延迟
    }

    // 精确的音频时间查询(基于 DSP 时钟,非游戏帧)
    public static double GetAudioTime() {
        return AudioSettings.dspTime;  // 双精度,基于音频硬件时钟
    }
}

// 使用 DSP 时间的判定器
public class DspTimeJudge : MonoBehaviour {
    public AudioSource music;

    void Update() {
        // 错误方式:Time.time 与音频实际播放不同步
        // float badTime = Time.time; // 受帧率波动影响

        // 正确方式:使用音频驱动级的时间戳
        double preciseTime = AudioSettings.dspTime;
        double songTime = music.time;  // AudioSource 内部使用 DSP 时间

        // 更精确的方式:根据采样位置计算
        ulong samplePos = (ulong)(AudioSettings.dspTime * AudioSettings.outputSampleRate);
        double exactMs = samplePos * 1000.0 / AudioSettings.outputSampleRate;
    }
}

输入延迟优化

鼠标点击从物理动作到游戏响应的时间链:

  1. 硬件鼠标轮询率(默认 125Hz = 8ms,电竞鼠标 1000Hz = 1ms)
  2. 操作系统输入队列(Windows 消息队列约 1~3ms)
  3. Unity 输入系统轮询(Update 中读取,帧率决定:60fps = 16ms 最坏情况)
  4. 渲染帧输出到显示器(显示器刷新率:144Hz = 7ms)
C#
using UnityEngine;

// 输入预测与回溯判定
public class InputLatencyCompensator : MonoBehaviour {
    // 预设延迟补偿量(通过校准工具测得,单位毫秒)
    public float inputLatencyMs = 30f;
    public float audioLatencyMs = 20f;

    // 总偏移:判定时刻 = 点击时刻 - 输入延迟 + 音频延迟
    private float TotalOffsetMs => audioLatencyMs - inputLatencyMs;

    // 判定核心:将点击时间修正到音频时间轴
    public Judgement JudgeWithLatency(float noteTargetMs, float clickRealtimeMs) {
        float adjustedClick = clickRealtimeMs - TotalOffsetMs;
        float diff = adjustedClick - noteTargetMs;
        return HitJudge.Judge(diff);
    }
}

// 校准工具:让玩家跟随节拍点击,统计平均偏差
public class LatencyCalibrator : MonoBehaviour {
    private List<float> offsets = new List<float>();
    private AudioSource metronome;

    void OnMetronomeBeat() {
        // 记录玩家点击与音频节拍的差值
        float playerClickTime = Time.realtimeSinceStartup * 1000;
        float beatTime = metronome.time * 1000;
        offsets.Add(playerClickTime - beatTime);
    }

    public float GetAverageOffset() {
        if (offsets.Count == 0) return 0;
        float sum = 0;
        foreach (var o in offsets) sum += o;
        return sum / offsets.Count;
    }
}

渲染帧同步与目标帧率

C#
using UnityEngine;

public class FrameSync : MonoBehaviour {
    void Start() {
        // 强制垂直同步:消除画面撕裂,但可能引入输入延迟
        QualitySettings.vSyncCount = 1;

        // 无垂直同步:最低输入延迟,但可能撕裂
        // QualitySettings.vSyncCount = 0;
        // Application.targetFrameRate = -1;

        // 节奏游戏推荐:固定刷新率匹配显示器
        // 若显示器 144Hz,目标 144fps
        Application.targetFrameRate = 144;

        // 确保 Update 在 FixedUpdate 之后(输入优先)
        // 或通过 Input System 的事件驱动模式绕过 Update
    }

    // 使用 OnRenderObject 或 LateUpdate 做最后的视觉修正
    // 将判定圈位置根据 DSP 时间做子帧插值
    void LateUpdate() {
        double dspNow = AudioSettings.dspTime;
        // 根据精确的音频时间微调视觉元素位置
    }
}
注意

降低 DSP 缓冲区大小会增加 CPU 负载,可能导致音频卡顿。需在目标设备上反复测试,找到延迟与稳定性的平衡点。建议从 512 样本开始逐步降低,直到出现爆音为止,再回调一个档位。

编程练习

在 Unity 中实现一个延迟校准工具:播放固定 120 BPM 的节拍器声音,玩家每拍点击一次鼠标,记录 16 次点击的平均偏差和标准差。根据统计结果计算建议的 inputLatencyMs 补偿值,并保存到 PlayerPrefs 供后续游戏使用。

osu!framework 与开源生态

通俗类比

如果 Unity 是一家完整的汽车厂(提供整车),那么 osu!framework 就是一级方程式赛车队的底盘——它不提供车门和座椅,但提供了达到极限性能所必需的一切:极致轻量、每一毫秒都可测量、每一个螺丝都可替换。这是专为节奏游戏打造的框架,而非通用游戏引擎。

什么是 osu!framework

osu!framework 是 osu! 官方团队开源的 C# 游戏框架,托管在 GitHub(ppy/osu-framework)。它是 osu!lazer(下一代 osu! 客户端)的底层基础,特点包括:

  • 完全开源:MIT 许可证,可自由修改和商用
  • 跨平台:Windows、macOS、Linux、iOS、Android
  • 极致的低延迟设计:从输入到渲染到音频,全链路优化
  • 内置节奏游戏所需组件:精确的音频引擎、谱面解析、判定系统
  • 基于 .NET:使用 C# 和现代 .NET 特性,性能优异

osu!framework 与 Unity 的核心差异

维度Unityosu!framework
定位通用游戏引擎osu! 专用框架(可扩展)
渲染基于 GameObject/Component基于 Scene Graph(节点树)
音频AudioSource(通用)BASS 音频库封装(低延迟专业级)
输入轮询式 Input 类事件驱动 + 原始设备访问
UIUGUI / UI Toolkit内置 osu! 风格 UI 系统
构建方式Editor 可视化操作代码优先,高度可编程

osu!framework 基础结构

C#
// 需通过 NuGet 安装 osu!framework:dotnet add package ppy.osu.Framework
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Screens;
using osuTK;

// 游戏入口
public class MyRhythmGame : Game {
    [BackgroundDependencyLoader]
    private void Load() {
        // 添加一个简单的圆圈精灵
        Add(new Circle {
            Size = new Vector2(100),
            Colour = Color4.Cyan,
            Position = new Vector2(200, 150)
        });
    }
}

// 屏幕管理:不同界面作为 Screen 切换
public class MainMenuScreen : Screen {
    private SpriteText title;

    [BackgroundDependencyLoader]
    private void Load() {
        InternalChildren = new Drawable[] {
            new Box {  // 背景
                RelativeSizeAxes = Axes.Both,
                Colour = new Color4(0.1f, 0.1f, 0.15f, 1)
            },
            title = new SpriteText {
                Text = "osu!framework Rhythm Game",
                Font = new FontUsage(size: 40),
                Anchor = Anchor.Centre,
                Origin = Anchor.Centre
            }
        };
    }
}

// 程序启动器
public static class Program {
    public static void Main() {
        using (var host = Host.GetSuitableHost("MyGame")) {
            host.Run(new MyRhythmGame());
        }
    }
}

osu!framework 的音频系统

osu!framework 底层封装了 BASS 音频库,提供专业级低延迟音频:

C#
using osu.Framework.Audio.Track;
using osu.Framework.Graphics.Audio;

public class OsuAudioPlayer : Drawable {
    private TrackBass track;  // BASS 音频轨道

    [BackgroundDependencyLoader]
    private void Load(AudioManager audio) {
        // 异步加载音频
        track = audio.GetTrackStore().Get("song.mp3");
    }

    public void Play() {
        track?.Start();
    }

    public double GetCurrentTime() {
        // 精确的音频播放位置(毫秒级精度)
        return track?.CurrentTime ?? 0;
    }

    // 音频速率调整(不改变音高,类似 osu! 中的 DT/HT 模式)
    public void SetRate(double rate) {
        if (track != null) track.Tempo.Value = rate;
    }
}

// osu! 特有的通道聚合混音器
public class RhythmMixer : DrawableAudioWrapper {
    // 可以同时管理背景音乐、音效、语音通道
    // 各自独立控制音量、均衡器、效果器
}

使用 osu!lazer 的谱面解析器

osu!lazer 的完整代码在 GitHub(ppy/osu),其中的 osu.Game 项目包含完整的 osu! 规则实现,可以直接引用或学习:

C#
// 通过 NuGet 引用 osu.Game 相关包
// 或直接克隆 https://github.com/ppy/osu 源码学习

using osu.Game.Beatmaps;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Objects;

public class OsuBeatmapLoader {
    // 解析 .osu 谱面文件为强类型对象
    public Beatmap<OsuHitObject> Parse(string filePath) {
        var stream = File.OpenRead(filePath);
        var decoder = new LegacyBeatmapDecoder();
        var beatmap = decoder.Decode(stream);
        return beatmap;
    }

    // 获取所有音符并按时间排序
    public List<OsuHitObject> GetSortedHitObjects(Beatmap<OsuHitObject> beatmap) {
        var objects = new List<OsuHitObject>(beatmap.HitObjects);
        objects.Sort((a, b) => a.StartTime.CompareTo(b.StartTime));
        return objects;
    }
}

// OsuHitObject 的子类代表不同音符类型
// HitCircle:普通点击圆
// Slider:滑条(包含路径点和持续时间)
// Spinner:转盘(持续按住直到结束)

开发路线建议

基于 osu!framework 开发节奏游戏的推荐路线:

  1. 入门:克隆 osu!framework 模板项目,运行示例,理解 Scene Graph
  2. 音频:接入 BASS 音频,实现精确节拍同步和变速播放
  3. 谱面:引用 osu.Game 的解析器,加载标准 .osu 文件
  4. 判定:实现 osu! 的完整判定系统(含时间窗口和位置窗口)
  5. 皮肤:支持 osu! 皮肤格式导入,复用社区皮肤资源
  6. 扩展:自定义规则集(Ruleset),开发原创节奏玩法
说明

osu!framework 的学习曲线比 Unity 陡峭得多——它假设开发者已经熟悉 .NET 生态和现代 C# 特性(泛型、LINQ、异步、依赖注入)。建议先完成 Unity 版本的项目,对节奏游戏的核心机制有深刻理解后,再迁移到 osu!framework 追求极致性能。

编程练习

访问 GitHub 仓库 ppy/osu-framework 和 ppy/osu,完成以下调研任务:1)阅读 README 了解构建要求;2)找到并阅读 osu.Game/Rulesets/Osu/ 目录下 HitCircle.cs 的源码,记录其判定逻辑与 Unity 版本的差异;3)在本地克隆仓库并尝试编译 osu!lazer 的测试项目,记录编译过程中遇到的依赖问题及解决方案。

课后练习与项目实践

基础练习

练习 1:C# 委托事件系统

编程练习

在 Unity 中创建一个 EventBus 单例类,实现发布-订阅模式。支持订阅任意类型的事件(如 ScoreChangedEventNoteMissedEvent),并在事件触发时分发给所有订阅者。使用此 EventBus 重构 ScoreManager 和 UIManager 之间的通信,消除直接的引用依赖。

参考实现

C#
using System;
using System.Collections.Generic;

public class EventBus {
    private static readonly Dictionary<Type, List<Delegate>> listeners = new Dictionary<Type, List<Delegate>>();

    public static void Subscribe<T>(Action<T> callback) {
        var type = typeof(T);
        if (!listeners.ContainsKey(type)) listeners[type] = new List<Delegate>();
        listeners[type].Add(callback);
    }

    public static void Unsubscribe<T>(Action<T> callback) {
        var type = typeof(T);
        if (listeners.ContainsKey(type)) listeners[type].Remove(callback);
    }

    public static void Publish<T>(T eventData) {
        var type = typeof(T);
        if (!listeners.ContainsKey(type)) return;
        foreach (var listener in listeners[type]) {
            ((Action<T>)listener)?.Invoke(eventData);
        }
    }
}

// 使用示例
public struct ScoreChangedEvent { public int newScore; public int combo; }

// 订阅
EventBus.Subscribe<ScoreChangedEvent>(OnScoreChanged);

// 发布
EventBus.Publish(new ScoreChangedEvent { newScore = 1500, combo = 12 });

练习 2:对象池优化

编程练习

osu! 中音符频繁生成和销毁会导致 GC 压力。实现一个 ObjectPool<T> 泛型对象池,预先实例化一批 HitCircle 对象,需要时从池中取出,用完后归还而非销毁。修改 NoteSpawner 使用对象池替代 Instantiate/Destroy。

参考实现

C#
using UnityEngine;
using System.Collections.Generic;

public class ObjectPool : MonoBehaviour {
    public GameObject prefab;
    public int initialSize = 50;
    private Queue<GameObject> pool = new Queue<GameObject>();

    void Start() {
        for (int i = 0; i < initialSize; i++) {
            GameObject obj = Instantiate(prefab, transform);
            obj.SetActive(false);
            pool.Enqueue(obj);
        }
    }

    public GameObject Get() {
        if (pool.Count > 0) {
            GameObject obj = pool.Dequeue();
            obj.SetActive(true);
            return obj;
        }
        return Instantiate(prefab, transform);
    }

    public void Return(GameObject obj) {
        obj.SetActive(false);
        pool.Enqueue(obj);
    }
}

练习 3:谱面文件解析器

编程练习

osu! 的标准谱面格式为 .osu 文件(INI 格式)。编写一个简化版的 OsuParser,解析以下格式的谱面数据:读取 [HitObjects] 节,每行格式为 "x,y,time,type,hitSound,addition"。输出 List<NoteData>,忽略 hitSound 和 addition 字段。

参考实现

C#
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

public class OsuParser {
    public static List<NoteData> Parse(string filePath) {
        var notes = new List<NoteData>();
        string[] lines = File.ReadAllLines(filePath);
        bool inHitObjects = false;

        foreach (var line in lines) {
            if (line == "[HitObjects]") { inHitObjects = true; continue; }
            if (line.StartsWith("[") && inHitObjects) break;
            if (!inHitObjects || string.IsNullOrWhiteSpace(line)) continue;

            var parts = line.Split(',');
            if (parts.Length < 5) continue;

            notes.Add(new NoteData {
                x = float.Parse(parts[0]),
                y = float.Parse(parts[1]),
                time = float.Parse(parts[2]),
                type = int.Parse(parts[3])
            });
        }
        return notes;
    }
}

综合项目扩展

扩展 1:滑条(Slider)实现

项目目标

在基础点击圆之上,实现 osu! 滑条功能:音符以一条可见路径呈现(由多个锚点定义),玩家需要在起点按下鼠标,沿路径拖动到终点,在终点松开。实现路径绘制(LineRenderer)、跟随进度指示器、按住判定逻辑。

扩展 2:本地排行榜

项目目标

实现一个本地排行榜系统:每首歌曲的每次游玩记录分数、准确率、评级和时间戳。在歌曲选择界面显示该歌曲的历史最佳成绩和前 5 次游玩记录。使用 JSON 文件持久化存储数据。

扩展 3:编辑器谱面预览

项目目标

在 Unity 中创建一个可视化谱面预览工具:读取 .osu 或 JSON 谱面文件,在 Scene 视图中用 Gizmos 绘制所有音符的位置和出现顺序(用数字标注)。支持按时间轴预览,当前时间之前的音符显示为灰色,之后的显示为彩色。

学习路径建议

完成本章学习后,建议按以下顺序深入:1)阅读 Unity 官方 2D 游戏开发完整教程;2)研究 osu! 开源客户端(osu!lazer 使用 C# 编写,代码在 GitHub 上公开);3)尝试接入更专业的音频分析库(如 Unity 的 Audio DSP)实现实时节拍检测;4)学习 Shader Graph 制作更华丽的击中特效。