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 别名 |
基础语法速览
// 程序入口 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 风格)
面向对象核心
// 继承与多态 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(出现时间,毫秒)、X、Y(位置坐标)、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 命名空间
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 的强大工具)
// 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)
// 异步加载资源是 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 项目和版本的中心工具,必须先安装。
- 访问
unity.com/download下载 Unity Hub - 运行安装程序,按提示完成安装
- 打开 Unity Hub,登录 Unity 账号(免费注册)
安装 Unity Editor
- 在 Unity Hub 中点击"Installs" → "Install Editor"
- 选择一个 LTS(长期支持)版本,如 2022.3 LTS 或 2021.3 LTS
- 在模块选择中勾选以下必要模块:
- Android Build Support(如需发布安卓)
- Documentation(离线文档)
- Visual Studio Editor(IDE 集成)
- 等待下载和安装完成(约 3-8GB)
配置外部脚本编辑器
Unity 推荐使用 Visual Studio 或 VS Code 编写 C# 脚本:
- 安装 Visual Studio Community(免费)或 VS Code
- 安装时勾选".NET desktop development"和"Game development with Unity"工作负载
- 在 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,后续即可批量生成相同配置的圆圈,各自独立修改。
// 在 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 的脚本会按以下顺序自动调用:
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 是每个游戏对象必备的基础组件,定义位置和变换:
// 修改位置和旋转 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_1 到 Circle_5,设置它们的位置为 X 轴上间隔 2 个单位的等距排列((0,0,0)、(2,0,0)、(4,0,0)...)。使用 Debug.Log() 输出每个创建的圆圈名称和位置。
2D 精灵系统
通俗类比
精灵(Sprite)就是电影中的角色形象。Sprite Renderer 是投影仪,把角色图片投射到屏幕上。你可以控制图片的大小、颜色、层级(谁在前谁在后)。在 osu! 中,每一个点击圆圈、滑条、背景都是精灵。
Sprite 的基本操作
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); } }
创建动态精灵对象
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 简单易用,适合入门:
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 即时播放。
音频组件基础
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(每分钟节拍数)对齐:
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 元素
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:
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 实现:
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! 本质上是点(鼠标点击)与圆(判定区域)的碰撞检测,不需要复杂的物理引擎,只需距离计算:
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! 判定系统实现
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(简单键值存储)
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 序列化(复杂数据结构)
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 | 背景音乐播放、时间同步、音效触发 |
谱面数据结构
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 的实际屏幕像素:
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 完整实现
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(音符生成器)
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(游戏总控)
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(分数系统)
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; }
场景管理与结算画面
// 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 中搭建完整项目:
- 创建场景:"MenuScene"、"GameScene"、"ResultScene"
- 摄像机设置:Projection = Orthographic,Size = 5.4(适配 16:9)
- 创建 HitCircle Prefab:
- 根对象:添加 HitCircle.cs
- 子对象 "Circle":SpriteRenderer,圆形 Sprite,SortingOrder=5
- 子对象 "ApproachCircle":SpriteRenderer,空心圆 Sprite,SortingOrder=4
- 子对象 "Overlay":SpriteRenderer,数字纹理,SortingOrder=6
- Canvas 设置:Render Mode = Screen Space - Overlay,Scaler = Scale With Screen Size(1920x1080)
- AudioManager 对象:两个 AudioSource 组件(BGM + SFX)
- 导入资源:背景音乐、击中音效、Miss 音效、圆形纹理、判定圈纹理
完整 Input 处理流程
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 音频低延迟配置
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; } }
输入延迟优化
鼠标点击从物理动作到游戏响应的时间链:
- 硬件鼠标轮询率(默认 125Hz = 8ms,电竞鼠标 1000Hz = 1ms)
- 操作系统输入队列(Windows 消息队列约 1~3ms)
- Unity 输入系统轮询(Update 中读取,帧率决定:60fps = 16ms 最坏情况)
- 渲染帧输出到显示器(显示器刷新率:144Hz = 7ms)
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; } }
渲染帧同步与目标帧率
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 的核心差异
| 维度 | Unity | osu!framework |
|---|---|---|
| 定位 | 通用游戏引擎 | osu! 专用框架(可扩展) |
| 渲染 | 基于 GameObject/Component | 基于 Scene Graph(节点树) |
| 音频 | AudioSource(通用) | BASS 音频库封装(低延迟专业级) |
| 输入 | 轮询式 Input 类 | 事件驱动 + 原始设备访问 |
| UI | UGUI / UI Toolkit | 内置 osu! 风格 UI 系统 |
| 构建方式 | Editor 可视化操作 | 代码优先,高度可编程 |
osu!framework 基础结构
// 需通过 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 音频库,提供专业级低延迟音频:
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! 规则实现,可以直接引用或学习:
// 通过 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 开发节奏游戏的推荐路线:
- 入门:克隆 osu!framework 模板项目,运行示例,理解 Scene Graph
- 音频:接入 BASS 音频,实现精确节拍同步和变速播放
- 谱面:引用 osu.Game 的解析器,加载标准 .osu 文件
- 判定:实现 osu! 的完整判定系统(含时间窗口和位置窗口)
- 皮肤:支持 osu! 皮肤格式导入,复用社区皮肤资源
- 扩展:自定义规则集(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 单例类,实现发布-订阅模式。支持订阅任意类型的事件(如 ScoreChangedEvent、NoteMissedEvent),并在事件触发时分发给所有订阅者。使用此 EventBus 重构 ScoreManager 和 UIManager 之间的通信,消除直接的引用依赖。
参考实现
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。
参考实现
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 字段。
参考实现
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 制作更华丽的击中特效。