C# 高级

C# 学习系列: C# 入门 → C# 基础 → C# 进阶 → C# 高级

接续《C# 入门》《C# 基础》《C# 进阶》。目标:从“能写类”走向“能组织集合、行为和运行时信息”,最后完成分层控制台俄罗斯方块。

配套项目目标框架:.NET 10,语言版本固定为 C# 12,启用可空引用类型检查。本文主体不依赖 C# 13/14 新语法;新增泛型约束单独说明。代码片段分为方法体片段、类型声明、完整文件,片段不应全部粘进同一个 Main。完整可运行代码见配套源码。方法体示例按需引入 System、System.Collections、System.Collections.Generic、System.Reflection、System.Threading;特定示例另列 using。涉及前文类型的片段应放在同一项目中。

目录与学习路线

  1. 简单数据结构类
  2. 泛型和约束
  3. 常见泛型数据结构类
  4. 委托和事件
  5. List 排序
  6. 协变逆变
  7. 多线程
  8. 预处理指令
  9. 反射和特性
  10. 迭代器
  11. 特殊语法
  12. 值类型和引用类型二
  13. 项目实践:俄罗斯方块
  14. 练习与答案、官方资料、完整项目源码

先理解集合保存什么,再学习用泛型约束元素;委托把方法作为参数,事件把通知与处理分离;迭代器统一遍历;反射与特性实现启动时注册;最后用接口连接游戏的输入、规则、输出与统计。

环境与运行

安装 .NET 10 SDK 后,在解压目录执行:

1
2
3
dotnet --info
dotnet run --project HighExamples/HighExamples.csproj
dotnet run --project TetrisGame/TetrisGame.csproj

HighExamples 是章节演示与规则检查程序,TetrisGame 是交互游戏。不需要第三方 NuGet 包。游戏需真实交互终端,至少 73 列 × 33 行;推荐 Windows Terminal、PowerShell、macOS Terminal 或 Linux 终端,不要用只显示文本的输出面板。游戏使用 ASCII 界面以避免中文字符宽度造成错位。

第一章:简单数据结构类

1.1 本质:容器、元素类型与访问规则

这些类位于 System.Collections,属于非泛型集合,元素以 object 接口形态暴露。存入 int 等值类型通常发生装箱,取出时需要正确拆箱;混合类型会把错误延后到运行时。新项目优先使用对应泛型集合,本章用于理解历史代码和容器行为。

1
2
3
4
5
6
7
8
9
using System;
using System.Collections;

ArrayList values = new ArrayList();
values.Add(10); // int 装箱
values.Add("ten"); // 引用类型值
int first = (int)values[0]!; // 正确拆箱
// long wrong = (long)values[0]!; // InvalidCastException:盒子里是 int
long converted = (int)values[0]!; // 先拆箱 int,再数值转换
类 抽象规则 典型用途 泛型替代
ArrayList 动态长度、按索引访问 旧代码异构列表 List
Stack 后进先出 LIFO 撤销、回溯 Stack
Queue 先进先出 FIFO 等待、任务排队 Queue
Hashtable 按键定位值 映射、查表 Dictionary<TKey,TValue>

“动态长度”指容器内部按需扩容,不代表数组本身能改变长度。Count 是元素数;Capacity 是部分容器已分配容量,两者不同。复杂度描述默认以元素数 n 计,哈希操作平均 O(1)、最坏 O(n),不保证所有输入均常数时间。

1.2 ArrayList:声明、增删改查和遍历

底层采用动态数组思想。索引访问 O(1),尾部 Add 摊还 O(1),中间插入、删除要移动后续元素,为 O(n)。

1
2
3
4
5
6
7
8
9
10
11
12
13
ArrayList list = new ArrayList { 10, 20, 30 };
list.Add(40); // 尾部增加
list.AddRange(new int[] { 50, 60 });
list.Insert(1, 15); // 在索引 1 前插入
list[0] = 99; // 修改
bool exists = list.Contains(30);
int index = list.IndexOf(30); // 未找到返回 -1
object? value = list[2]; // 按索引查询
list.Remove(20); // 删除首次匹配值
list.RemoveAt(1); // 删除索引位置
foreach (object? item in list) Console.WriteLine(item);
for (int i = 0; i < list.Count; i++) Console.WriteLine(list[i]);
list.Clear();

索引必须满足 0 <= index < Count;Insert 可在 Count 位置插入。Remove(20) 与 RemoveAt(20) 含义不同。Sort() 要求元素可互相比较,混入字符串与整数可能失败。一般不要在 foreach 中增删原集合;可倒序 for 删除,或先记录要删除的元素。

1.3 Stack:后进先出

栈顶是唯一常规进出口。Push 压栈,Pop 弹出并删除,Peek 只看栈顶。底层通常采用可扩容数组;Push 摊还 O(1),Peek/Pop O(1),Contains O(n)。

1
2
3
4
5
6
7
8
Stack stack = new Stack();
stack.Push("第一步");
stack.Push("第二步");
Console.WriteLine(stack.Peek()); // 第二步,仍在栈中
if (stack.Count > 0) Console.WriteLine(stack.Pop()); // 第二步
Console.WriteLine(stack.Contains("第一步"));
foreach (object? item in stack) Console.WriteLine(item); // 栈顶到栈底
stack.Clear();

空栈 Peek/Pop 抛 InvalidOperationException。非泛型 Stack 没有按索引修改或删除中间项的常规 API;如确有需要,应弹出、重建,或重新选择容器。不要把“增删改查”理解成每种容器都必须支持随机修改。

1.4 Queue:先进先出

队尾入队,队首出队,适合事件和任务排队。可用循环数组实现;Enqueue 摊还 O(1),Dequeue/Peek O(1),Contains O(n)。

1
2
3
4
5
6
7
8
Queue queue = new Queue();
queue.Enqueue("玩家A");
queue.Enqueue("玩家B");
Console.WriteLine(queue.Peek()); // 玩家A
if (queue.Count > 0) Console.WriteLine(queue.Dequeue());
Console.WriteLine(queue.Contains("玩家B"));
foreach (object? item in queue) Console.WriteLine(item); // 队首到队尾
queue.Clear();

空队列的 Peek/Dequeue 会抛异常。它不提供索引器,也不支持直接修改队中第 k 项。游戏中可把“方块落地事件”按顺序入队,交给统计线程消费。

1.5 Hashtable:键值映射

哈希函数把键映射到桶,再用相等比较区分冲突。键应保持哈希与相等语义稳定;把参与 GetHashCode 的字段改掉,可能导致键还在表中却查不到。键不可为 null,值可以为 null。

1
2
3
4
5
6
7
8
9
10
11
12
Hashtable table = new Hashtable();
table.Add("Alice", 100); // 重复键抛异常
table["Bob"] = 80; // 不存在则新增
table["Alice"] = 120; // 存在则覆盖
if (table.ContainsKey("Alice")) Console.WriteLine(table["Alice"]);
bool hasScore = table.ContainsValue(80); // O(n)
table.Remove("Bob");
foreach (DictionaryEntry entry in table)
Console.WriteLine($"{entry.Key}: {entry.Value}");
foreach (object key in table.Keys) Console.WriteLine(key);
foreach (object? v in table.Values) Console.WriteLine(v);
table.Clear();

读取不存在的键返回 null,不能仅凭结果 null 区分“键不存在”与“值是 null”。遍历顺序不应作为业务契约。以上集合默认均不适合多线程并发读写,线程安全需另外设计。

第二章:泛型和约束

2.1 概念、分类、作用

泛型把具体类型推迟为类型参数,仍保留编译期类型检查。List<int> 只能接收 int,避免非泛型集合频繁装箱和强制转换;但泛型不保证绝对没有装箱,例如把 T 转成 object 时仍可能装箱。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public sealed class Box<T> // 泛型类
{
public T Value { get; }
public Box(T value) => Value = value;
}
public interface IRepository<T> // 泛型接口
{
void Add(T value);
T Get(int index);
}
public static class Tools
{
public static void Swap<T>(ref T a, ref T b) // 泛型方法
{
T temporary = a; a = b; b = temporary;
}
}

使用:Box<int> box = new Box<int>(3);;Tools.Swap(ref x, ref y) 通常可推断 T,也可显式写 Tools.Swap<int>。泛型方法需要自己声明类型参数;一个普通方法位于泛型类里,不代表该方法自身也是泛型方法。除此之外还有泛型结构体和泛型委托,例如 Nullable<T> 与 Func<T,TResult>。

2.2 传统六种约束

没有约束时,编译器不能假设 T 有某个业务方法。约束写在声明后,用 where 描述调用者必须满足的条件。

约束 含义 可依赖的能力
where T : struct 非可空值类型 值类型语义;隐含 new()
where T : class 引用类型 引用语义;可空上下文中要求非可空
where T : new() 有 public 无参构造函数的非抽象类型 可以 new T()
where T : Base Base 或派生类型 可以使用 Base 的可访问成员
where T : IFoo 实现接口 可以调用接口成员;结构体也可满足
where T : U T 可赋给 U 的类型约束 描述两个类型参数之间关系
1
2
3
4
5
6
7
8
9
10
11
12
public interface IHasId { string Id { get; } }
public class Entity : IHasId { public string Id { get; set; } = "entity"; }
public sealed class Player : Entity { }
public sealed class Factory<T> where T : Entity, IHasId, new()
{
public T Create() => new T();
public string ReadId(T value) => value.Id;
}
public sealed class Related<TBase, TDerived> where TDerived : TBase, new()
{
public TBase Create() => new TDerived();
}

多个约束用逗号连接,多个参数分别写 where。主约束在前,接口居中,new() 通常最后。struct 与 class 不能组合,struct 不能再写 new();基类约束不能与 class/struct 主约束同时使用。接口可有多个。约束不是运行时 if,也不能只靠约束差异区分两个签名相同的重载。

2.3 不止六种:补充与版本边界

传统六种是入门分类,并非现代 C# 完整清单。notnull 要求非可空类型(违反时通常产生可空分析警告);unmanaged 要求不含托管引用的非可空值类型,不能与 struct/new() 组合;Enum、Delegate 可作为约束;default 用于重写或显式接口实现中的特定约束消歧。C# 13 还有 allows ref struct 反约束,本文 C# 12 项目不使用。查看官方“类型参数约束”文档时应留意语言版本。

1
2
public static T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b;

约束应表达算法真正需要的能力。仅需比较时约束 IComparable,不必限制为某个具体业务类。

第三章:常见泛型数据结构类

3.1 List:动态数组

位于 System.Collections.Generic。逻辑上有序,重复元素合法;底层连续数组保存 T 的值,若 T 是引用类型,数组槽位保存对象引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
List<int> scores = new List<int>(capacity: 8) { 10, 20, 30 };
scores.Add(40);
scores.AddRange(new[] { 50, 60 });
scores.Insert(1, 15);
scores[0] = 99;
int first = scores[0];
bool found = scores.Contains(30);
int at = scores.IndexOf(30);
int firstLarge = scores.Find(x => x > 40); // 没找到返回 default(int),即 0
List<int> large = scores.FindAll(x => x > 40);
scores.Remove(20); // 首次匹配
scores.RemoveAt(1);
scores.RemoveAll(x => x < 20);
foreach (int score in scores) Console.WriteLine(score);
for (int i = 0; i < scores.Count; i++) Console.WriteLine(scores[i]);
scores.Clear();

Find 返回 default 可能与真实元素混淆,可用 FindIndex(未找到 -1)。Count 不是 Capacity,Clear 后容量通常保留。TrimExcess() 可请求压缩多余容量,但不应频繁调用。ToArray() 得到新的数组,元素仍是浅复制。

3.2 Dictionary<TKey,TValue>

泛型哈希表,键唯一。TKey 受 notnull 约束;使用字符串键时明确选择比较规则。

1
2
3
4
5
6
7
8
9
10
11
12
Dictionary<string, int> scores = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
scores.Add("Alice", 100);
bool added = scores.TryAdd("ALICE", 200); // false,不覆盖
scores["Bob"] = 80;
scores["Alice"] = 120;
if (scores.TryGetValue("alice", out int score)) Console.WriteLine(score);
Console.WriteLine(scores.ContainsKey("Bob"));
Console.WriteLine(scores.ContainsValue(80));
bool removed = scores.Remove("Bob");
foreach (KeyValuePair<string, int> pair in scores)
Console.WriteLine($"{pair.Key}={pair.Value}");
scores.Clear();

不存在的键使用索引器读取会抛 KeyNotFoundException,TryGetValue 更适合常见的查询失败。键比较由 IEqualityComparer 决定,Equals 相等的键必须返回相同哈希码,反过来不成立。不要用字典遍历顺序表达排名;排名先复制到 List 再排序。

3.3 顺序存储与链式存储

操作 List 带头尾指针的单向链表 LinkedList 双向链表
索引访问 O(1) O(n) 不提供索引器,定位 O(n)
尾部增加 摊还 O(1) O(1) O(1)
头部增加/删除 O(n) O(1) O(1)
已知位置插入 移动后续项 O(n) 已知前驱 O(1) 已知节点 O(1)
按值查找 O(n) O(n) O(n)
空间局部性 较好 较差,节点有额外引用 较差,含前后引用

链表插入快的前提是已经定位到合适节点,按值找节点的成本不能忽略。逻辑相邻不代表内存相邻。


flowchart TD
  H["head"] --> A["节点 A:值 + next"]
  A --> B["节点 B:值 + next"]
  B --> C["节点 C:值 + null"]
  T["tail"] --> C

3.4 实现单向链表

配套 Collections/SinglyLinkedList.cs 提供完整实现,含边界检查、Count、头尾节点、索引器、插入删除、查询、清空及标准枚举器。先看关键算法(类内部片段):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private sealed class Node
{
public T Value;
public Node? Next;
public Node(T value) => Value = value;
}
private Node? head, tail;
public int Count { get; private set; }
public void AddLast(T value)
{
Node node = new Node(value);
if (tail is null) head = node;
else tail.Next = node;
tail = node;
Count++;
}

插入到中间位置时,先保存旧后继,再重接引用:previous.Next = new Node(value) { Next = previous.Next };。删除时 previous.Next = previous.Next.Next,删除尾节点还要把 tail 指回 previous;删除最后一个节点必须同时清空 head 与 tail。完整实现维护 version,在遍历期间修改集合会抛异常。

1
2
3
4
5
6
7
8
SinglyLinkedList<int> list = new SinglyLinkedList<int>();
list.AddFirst(20); list.AddLast(40); list.Insert(1, 30);
list[0] = 10;
Console.WriteLine(list.IndexOf(30)); // 1
if (list.TryGet(2, out int value)) Console.WriteLine(value); // 40
list.Remove(30); list.RemoveAt(0);
foreach (int item in list) Console.WriteLine(item); // 40
list.Clear();

3.5 LinkedList:双向链表

系统类的节点为 LinkedListNode<T>,具有 Value、Previous、Next、List。拿到节点后能常数时间在前后插入或删除。它既不是 List 的子类,也没有 [index] 索引器。

1
2
3
4
5
6
7
8
9
10
11
12
13
LinkedList<string> route = new LinkedList<string>();
LinkedListNode<string> a = route.AddLast("A");
LinkedListNode<string> b = route.AddAfter(a, "B");
route.AddBefore(b, "X");
route.AddFirst("Start");
b.Value = "B2";
LinkedListNode<string>? found = route.Find("X");
if (found is not null) route.Remove(found);
route.Remove("A");
foreach (string stop in route) Console.WriteLine(stop);
for (LinkedListNode<string>? node = route.First; node is not null; node = node.Next)
Console.WriteLine(node.Value);
route.RemoveFirst(); route.RemoveLast(); route.Clear();

RemoveFirst/RemoveLast 在空链表上抛异常。节点不能同时属于多个链表;删除后可将脱离的节点加入另一个链表。修改节点 Value 与改变链结构是不同操作;不要依赖不同集合对遍历期修改的差异,统一采用“先收集,再修改”的清晰策略。

第四章:委托和事件

4.1 委托的本质、声明与使用

委托是类型安全的方法调用对象。一个委托类型规定参数和返回值形状;一个委托实例持有目标对象与方法组成的调用列表。实例方法需要目标对象,静态方法通常不需要。声明类型、定义方法、创建实例、调用实例是四个步骤。

1
2
3
4
5
6
7
8
9
10
11
12
public delegate int Calculator(int a, int b); // 类型声明
public static class DelegateLesson
{
private static int Add(int a, int b) => a + b;
public static void Run()
{
Calculator operation = Add; // 方法组转换
Calculator other = new Calculator(Add); // 显式写法
Console.WriteLine(operation(2, 3)); // 5
Console.WriteLine(other.Invoke(4, 5));
}
}

委托让调用者提供变化的行为,例如“按哪种规则排序”,而不是在算法中写死。委托本身是引用类型,赋值不等于执行方法。

4.2 多播机制

通常所说“多播系统”是委托的多播调用机制:用 += 组合,用 -= 移除一个匹配的订阅,调用时按列表顺序同步执行。

1
2
3
4
5
6
7
8
Action? notify = null;
Action first = () => Console.WriteLine("保存");
Action second = () => Console.WriteLine("刷新");
notify += first;
notify += second;
notify?.Invoke(); // 保存,然后刷新
notify -= first;
notify?.Invoke(); // 刷新

委托不可变,组合产生新的委托值。重复订阅会重复调用;移除通常移除最后一个匹配项。普通调用中某个处理器抛异常,后续处理器不会继续执行。非 void 多播委托只能直接得到最后一个处理器的返回值,需要收集所有结果时逐个遍历 GetInvocationList()。多播默认不产生多线程。

4.3 系统委托

类型 形状 例子
Action 无返回值 Action<string> 接收一个 string
Func 最后一个泛型参数是返回类型 Func<int,int,int> 两个 int 输入、一个 int 输出
Predicate T → bool List.Find/RemoveAll 的判断
Comparison (T,T) → int List.Sort 的比较
EventHandler (sender,args) → void 事件通知
1
2
3
4
Action<string> print = text => Console.WriteLine(text);
Func<int, int, int> sum = (a, b) => a + b;
Predicate<int> positive = x => x > 0;
Comparison<int> ascending = (a, b) => a.CompareTo(b);

这些已经够用时,不必为每个方法形状创建新委托。业务语义特别明确时,自定义委托仍有价值。

4.4 事件:限制外部调用权

事件基于委托提供订阅/取消订阅机制。外部代码只能 +=、-=,不能覆盖整个调用列表,也不能直接触发事件。发布者决定何时发生,订阅者决定怎样响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public sealed class ScoreEventArgs : EventArgs
{
public int Added { get; }
public ScoreEventArgs(int added) => Added = added;
}
public sealed class ScoreBoard
{
public event EventHandler<ScoreEventArgs>? ScoreChanged;
public int Score { get; private set; }
public void Add(int points)
{
if (points < 0) throw new ArgumentOutOfRangeException(nameof(points));
Score += points;
ScoreChanged?.Invoke(this, new ScoreEventArgs(points));
}
}

订阅与取消(方法体片段):

1
2
3
4
5
ScoreBoard board = new ScoreBoard();
EventHandler<ScoreEventArgs> handler = (sender, e) => Console.WriteLine($"增加 {e.Added}");
board.ScoreChanged += handler;
board.Add(100);
board.ScoreChanged -= handler;

长寿命发布者可能通过委托引用让订阅者保持可达,应在场景退出、对象释放时取消订阅。若以后要取消匿名处理器,先保存委托变量,重新写一段看似相同的 lambda 不应被当成可靠取消方式。?.Invoke 避免一次空检查与调用间委托字段变化造成的空引用,但不会自动保证业务数据线程安全。

俄罗斯方块在锁定后发布 PieceLocked,游戏场景写日志,统计服务收集数据;规则层不必知道日志如何显示。

4.5 匿名函数和 lambda

匿名方法:Func<int,int> square = delegate(int x) { return x*x; };。Lambda 可用表达式体 x => x*x,或语句体 x => { return x*x; }。无参数写 () => ...,多个参数加括号。

闭包捕获的是变量,不是声明时的一份固定快照:

1
2
3
4
5
6
7
List<Action> actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
int copy = i;
actions.Add(() => Console.WriteLine(copy));
}
foreach (Action action in actions) action(); // 0、1、2

若直接捕获 for 的 i,循环后调用时通常都看到 3。闭包可能延长局部变量及其引用对象的存活时间。static lambda 禁止捕获外部局部状态,例如 static x => x > 0,有助于看清依赖。Lambda 可转成委托,某些场景也可转成表达式树,两者用途不同,本教程以委托为主。

第五章:List 排序

5.1 自带 Sort 与 Reverse

Sort() 按默认比较器原地升序排列,元素需支持默认比较。Reverse() 只反转当前顺序,并不负责把无序列表变成降序。Sort(index,count,comparer) 可排序局部区间。

1
2
3
List<int> values = new List<int> { 8, 1, 3 };
values.Sort(); // 1,3,8
values.Sort((a, b) => b.CompareTo(a)); // 8,3,1

比较函数结果只看符号:负数表示 a 在前,0 表示同序,正数表示 a 在后。不要写 a-b 比较所有整数,极值相减可能溢出。比较应自洽且具有传递性,不应依赖每次变化的随机值。

5.2 自定义类排序

有天然默认顺序时实现 IComparable<T>;同一对象有多种排序规则时,用 IComparer<T> 或 Comparison。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public sealed class PlayerScore : IComparable<PlayerScore>
{
public string Name { get; }
public int Score { get; }
public PlayerScore(string name, int score) { Name = name; Score = score; }
public int CompareTo(PlayerScore? other)
{
if (other is null) return 1;
int result = other.Score.CompareTo(Score); // 分数降序
return result != 0 ? result : StringComparer.Ordinal.Compare(Name, other.Name);
}
}
public sealed class NameComparer : IComparer<PlayerScore>
{
public int Compare(PlayerScore? x, PlayerScore? y)
{
if (ReferenceEquals(x, y)) return 0;
if (x is null) return -1;
if (y is null) return 1;
return StringComparer.Ordinal.Compare(x.Name, y.Name);
}
}
1
2
3
4
5
6
7
List<PlayerScore> players = new List<PlayerScore>
{
new PlayerScore("Bob", 100), new PlayerScore("Alice", 100), new PlayerScore("Carol", 200)
};
players.Sort(); // Carol、Alice、Bob
players.Sort(new NameComparer()); // Alice、Bob、Carol
players.Sort((a, b) => b.Score.CompareTo(a.Score)); // 只比较分数

List<T>.Sort 是不稳定排序,不承诺同分对象仍保持原先相对顺序。需要稳定性时可使用 LINQ OrderBy/ThenBy 后 ToList()(需要 System.Linq),或显式把原始索引作为最终比较条件。游戏的方块统计先按次数降序,再按枚举值排序,确保同分显示确定。

第六章:协变逆变

6.1 从替换能力理解

假设 Dog 继承 Animal。一个 Dog 可放到需要 Animal 的位置,这是普通引用转换。泛型不自动把这种关系传递到所有容器:List<Dog> 不能赋给 List<Animal>,否则接收方就可能向狗列表添加猫。

协变保留方向:生产 Dog 的来源可当作生产 Animal 的来源。逆变反转方向:能够处理所有 Animal 的处理器,当然能处理 Dog。它们体现“在使用处可安全替换”,不能把里氏替换原则等同于 is/as 类型转换。

6.2 out 协变与 in 逆变

1
2
3
4
5
6
7
8
9
10
11
12
public class Animal { public string Name { get; set; } = "animal"; }
public sealed class Dog : Animal { }
public interface ISource<out T> { T Get(); }
public interface ISink<in T> { void Put(T value); }
public sealed class DogSource : ISource<Dog>
{
public Dog Get() => new Dog();
}
public sealed class AnimalSink : ISink<Animal>
{
public void Put(Animal value) => Console.WriteLine(value.Name);
}
1
2
3
4
5
6
7
8
9
10
ISource<Dog> dogSource = new DogSource();
ISource<Animal> animalSource = dogSource;
Animal animal = animalSource.Get();
ISink<Animal> allAnimalSink = new AnimalSink();
ISink<Dog> dogSink = allAnimalSink;
dogSink.Put(new Dog());
Func<Dog> makeDog = () => new Dog();
Func<Animal> makeAnimal = makeDog;
Action<Animal> inspect = a => Console.WriteLine(a.Name);
Action<Dog> inspectDog = inspect;

out/in 变体标注仅用于泛型接口和泛型委托的类型参数,不能直接标注泛型类。这不是 ref/out 传参语法。简单记忆:out 主要向外产出 T;in 主要消费 T,复杂嵌套还有更精确的位置规则。

变体转换针对引用类型参数:IEnumerable<int> 不能协变成 IEnumerable<object>,需要逐个投影并装箱。IEnumerable<Dog> 可转换为 IEnumerable<Animal>,即使实际底层对象是 List,也不意味着 List 类型本身变成协变。数组有历史性的引用协变,但向 object[] a = new string[1] 写整数会在运行时抛 ArrayTypeMismatchException。

第七章:多线程

7.1 进程、线程、并发与并行

进程是操作系统管理程序资源与隔离的单位;线程是执行流,同一进程中的线程通常共享托管堆与静态数据,各有调用栈。并发是多个任务交错推进,并行是同时执行。单核也能并发,多线程不保证更快,锁、切换和调度都存在成本。

游戏主线程负责输入、规则和绘制,避免两个线程同时修改棋盘或写控制台。后台线程仅接收落地信息并更新统计,用锁保护队列和结果。

7.2 声明、启动、后台与休眠

1
2
3
4
5
6
7
8
9
10
11
using System.Threading;

Thread worker = new Thread(() =>
{
Console.WriteLine("工作开始");
Thread.Sleep(50); // 休眠当前线程,时间不保证精确
});
worker.Name = "demo";
worker.IsBackground = true; // 启动前设置
worker.Start(); // 同一 Thread 实例不能启动两次
worker.Join(); // 当前线程等待 worker 结束

前台线程会让进程继续存活;只剩后台线程时进程可结束,后台工作不保证完成。因此后台线程也需明确停止和 Join。Sleep 不释放已持有的 lock,不是可靠的同步协议。Join 不应在持有工作线程需要的锁时调用,否则可能死锁。

7.3 释放:协作退出而非强制销毁

Thread 本身不实现 IDisposable,没有通用的“释放线程”方法。让入口函数返回,线程就结束;之后线程对象由 GC 管理。不要用 Abort 作为退出方案,现代 .NET 不支持它。用取消令牌、停止标记和信号实现退出,最后释放自己创建的同步资源。

1
2
3
4
5
6
7
8
9
10
11
12
using CancellationTokenSource stop = new CancellationTokenSource();
Thread worker = new Thread(() =>
{
while (!stop.Token.WaitHandle.WaitOne(30))
{
// 每次只做有限工作;循环检查取消
}
}) { IsBackground = true };
worker.Start();
Thread.Sleep(100);
stop.Cancel();
worker.Join(); // 退出后,using 再释放 stop

真实工作线程还要捕获并报告预期异常,不能让错误静默丢失;不要吞掉所有异常继续运行损坏状态。

7.4 共享数据与同步

counter++ 是读、计算、写,不是单个原子操作。

1
2
3
4
5
int counter = 0;
Thread first = new Thread(() => { for (int i = 0; i < 10000; i++) Interlocked.Increment(ref counter); });
Thread second = new Thread(() => { for (int i = 0; i < 10000; i++) Interlocked.Increment(ref counter); });
first.Start(); second.Start(); first.Join(); second.Join();
Console.WriteLine(counter); // 20000
工具 适用场景 注意
lock 多字段不变量、普通集合读写 所有相关访问使用同一个锁;锁对象私有且稳定
Interlocked 计数、交换等简单原子操作 不能自动保护多步业务事务
volatile 部分字段的可见性与顺序保证 不会使 ++ 或集合操作原子化
AutoResetEvent 通知有新工作 信号可合并;真实数据仍放队列
ConcurrentQueue 并发入队出队 多步流程仍要设计完成条件
Task/线程池 通常的后台任务 异步 I/O 通常优先 async/await

不要锁字符串、typeof 或公开的 this,以免外部代码使用相同锁。避免持锁调用未知事件处理器,减少重入和死锁风险。集合的“安全遍历”可先在锁内复制快照,再在锁外排序和显示。游戏统计线程采用这条规则,结束时先停止接收、清空队列、Join,再读取最终结果。

第八章:预处理指令

预处理指令影响编译器如何处理源码,并非运行期分支。C# 没有 C/C++ 式文本宏替换,#define 只定义符号,不保存任意数字值。指令通常单独占一行,无分号。

指令 作用 场景
#define / #undef 定义、取消当前文件条件符号 必须位于普通代码之前
#if / #elif / #else / #endif 条件编译 DEBUG、平台或自定义符号
#region / #endregion 编辑器折叠区域 不改变执行结果
#warning / #error 编译警告、编译错误 配置检查
#nullable enable/disable/restore 控制可空分析上下文 新旧代码迁移
#pragma warning disable/restore 控制指定编译警告 应限制作用范围
#line 调整报告的源码位置 生成代码
#pragma checksum 提供源码校验信息 调试和生成工具
1
2
3
4
5
6
7
8
9
10
#define LOCAL_TRACE
using System;

#if DEBUG && LOCAL_TRACE
Console.WriteLine("调试构建,启用本文件日志");
#elif DEBUG
Console.WriteLine("调试构建");
#else
Console.WriteLine("发布构建");
#endif

项目级定义可写在 csproj 的 <DefineConstants>$(DefineConstants);GAME_TRACE</DefineConstants>。DEBUG 通常由 Debug 配置提供,不能仅靠“正在调试”判断符号是否存在。运行 dotnet build -c Release 可检查另一条分支。被排除的分支不会生成对应 IL,应对重要配置分别编译验证。

#if 与 [Conditional("DEBUG")] 不同:前者排除代码,后者主要控制调用点是否保留,该方法通常仍存在于程序集。#nullable 不会在运行时自动阻止 null,也不改变对象布局。

第九章:反射和特性

9.1 程序集、元数据与反射

程序集是 .NET 可部署、可版本化的构建单元,常见为托管 dll;其中包含清单、类型和成员元数据,以及通常的 IL 方法体。命名空间是逻辑命名组织,不等于程序集,也不等于文件夹。原生 DLL 不一定是 .NET 程序集,不能一律用 Assembly.Load 加载。

元数据描述“有哪些类型、成员、参数和标记”。反射在运行时读取这些描述,也可构造对象、读写成员、调用方法。用途包括编辑器检查器、序列化、测试发现、插件注册;代价包括运行时错误、查找开销、裁剪/AOT 兼容问题。固定依赖应优先直接调用或接口,反射适合启动阶段发现,结果缓存后使用。


flowchart TD
  A["Assembly:程序集"] --> T["Type:类型描述"]
  T --> M["MethodInfo / PropertyInfo / FieldInfo"]
  T --> C["ConstructorInfo"]
  T --> X["Attribute:附加元数据"]

9.2 Type:它描述类型,而不是业务对象

Type 自身是抽象类,运行时给出具体实现。获取方式:typeof(Player)、instance.GetType()、Type.GetType("命名空间.类型, 程序集名")。GetType 不可直接对 null 调用;typeof 获取编译期指定类型,实例 GetType 获取实际运行时类型。

成员/典型签名 返回值、含义 使用注意
Name / FullName / AssemblyQualifiedName 简名、带命名空间名、带程序集身份名 部分类型 FullName 可为 null
BaseType / Assembly 父类型描述、所在程序集 接口的 BaseType 为 null
IsClass / IsInterface / IsValueType / IsEnum 类型类别标记 string 是类,数组是引用类型
IsAbstract / IsSealed 是否抽象、密封 静态类通常两者都为 true
IsGenericType / IsGenericTypeDefinition 是否泛型、是否开放泛型定义 List 与 List<> 有区别
GetGenericArguments() Type[],泛型参数或实参 List 得到 int
GetGenericTypeDefinition() 泛型类型定义 非泛型类型调用会失败
MakeGenericType(params Type[]) 以实参构造泛型 Type 在泛型定义上调用,需满足约束
GetInterfaces() Type[],实现的接口 包括继承得到的接口
IsAssignableFrom(Type) 来源类型是否可赋给当前类型 typeof(Animal).IsAssignableFrom(typeof(Dog))
IsInstanceOfType(object?) 对象能否赋给该类型变量 null 返回 false
GetConstructor(Type[]) ConstructorInfo? 用参数类型消除重载歧义
GetMethod(string,Type[]) MethodInfo? 精确查找某个重载
GetMethods(BindingFlags) MethodInfo[] 明确 public/nonpublic、instance/static
GetProperty(string) / GetField(string) PropertyInfo? / FieldInfo? 没找到返回 null
GetProperties() / GetFields() / GetEvents() 对应成员描述数组 默认主要查 public 成员

BindingFlags 是枚举,可组合 BindingFlags.Public | BindingFlags.Instance。仅写 NonPublic 没有 Instance/Static,往往得不到预期结果。非公开继承成员的查找规则较细,特别是基类 private 成员,不能理解为“加 NonPublic 就能查到全部”。

完整反射示例(独立文件):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using System;
using System.Reflection;

public sealed class ReflectPlayer
{
public string Name { get; set; }
public int Score;
public ReflectPlayer(string name) => Name = name;
public int Add(int points) { Score += points; return Score; }
}
public static class ReflectionLesson
{
public static void Run()
{
Type type = typeof(ReflectPlayer);
Console.WriteLine(type.FullName);
ConstructorInfo ctor = type.GetConstructor(new[] { typeof(string) })
?? throw new MissingMethodException(type.FullName, ".ctor");
object player = ctor.Invoke(new object[] { "Alice" });
PropertyInfo name = type.GetProperty(nameof(ReflectPlayer.Name))!;
name.SetValue(player, "Bob");
Console.WriteLine(name.GetValue(player));
FieldInfo score = type.GetField(nameof(ReflectPlayer.Score))!;
score.SetValue(player, 10);
MethodInfo add = type.GetMethod(nameof(ReflectPlayer.Add), new[] { typeof(int) })!;
object? result = add.Invoke(player, new object[] { 5 });
Console.WriteLine(result); // 15
Type closed = typeof(System.Collections.Generic.List<>).MakeGenericType(typeof(int));
Console.WriteLine(closed.IsGenericType); // True
}
}

MethodInfo.Invoke(object? obj, object?[]? parameters):实例方法传实例,静态方法传 null;结果为 object?,void 返回 null。参数类型/个数不匹配会失败,业务方法内部异常通常包装在 TargetInvocationException.InnerException 中。示例用 ! 是基于已知类型成员存在,不适合把任意配置都不检查地强制解引用。

9.3 Assembly:查找与加载托管程序集

API 含义 示例/注意
typeof(T).Assembly 已知类型所在程序集 最直接可靠的定位方式
Assembly.GetExecutingAssembly() 当前执行代码所在程序集 不等于永远是入口程序
Assembly.GetEntryAssembly() 进程入口托管程序集 某些宿主环境可为 null
Assembly.GetCallingAssembly() 调用方程序集 内联等会影响,不宜作安全判据
Assembly.GetName() AssemblyName,含名称版本等 FullName 是完整身份
Assembly.Load(AssemblyName/string) 按身份请求加载 依赖运行时加载上下文
Assembly.LoadFrom(string path) 从文件路径加载 应用信任边界内使用
GetTypes() 全部定义类型 依赖缺失可能抛 ReflectionTypeLoadException
GetExportedTypes() 对外可见的类型 不等于全部类型
GetType(string,bool) 按完整名查询类型 不存在可返回 null 或抛错
CreateInstance(string) 按类型名构造 通常依赖公开无参构造
GetManifestResourceNames() 内嵌资源名数组 名称一般含命名空间
GetManifestResourceStream(string) Stream? 找不到返回 null;使用后 Dispose
1
2
3
4
Assembly assembly = typeof(ReflectPlayer).Assembly;
Console.WriteLine(assembly.GetName().Name);
foreach (Type type in assembly.GetTypes()) Console.WriteLine(type.FullName);
Type? target = assembly.GetType(typeof(ReflectPlayer).FullName!, throwOnError: false);

Assembly.LoadFrom("Plugins/MyPlugin.dll") 不是沙箱,也不能安全执行不可信代码。需要可卸载插件时使用专门的 AssemblyLoadContext,单独的 Assembly 没有通用 Unload 方法。程序集加载、文件路径、类型发现异常需要报告上下文,不能直接忽略“加载失败的类型”。游戏只扫描自身程序集,避免无关插件加载的复杂性。

9.4 Activator:根据运行时类型构造对象

Activator 是静态工厂工具,不能 new Activator。

API 作用 条件/返回
CreateInstance() 按泛型类型构造 通常要求可用无参构造,否则运行时失败
CreateInstance(Type) 按 Type 构造 object?;不可直接实例化接口/抽象类
CreateInstance(Type, object?[]?) 匹配参数构造 反射选择构造重载,错误会在运行时出现
CreateInstance(Type, bool nonPublic) 可允许非公开无参构造 不应借此破坏业务边界
CreateInstance(Type, BindingFlags, Binder?, object?[]?, CultureInfo?) 控制绑定 高级重载,通常不需要
CreateInstance(string,string) 按程序集名和类型名创建 部分重载返回 ObjectHandle,需 Unwrap;现代代码通常优先 Type 重载
1
2
3
Type playerType = typeof(ReflectPlayer);
object? raw = Activator.CreateInstance(playerType, new object[] { "Alice" });
if (raw is ReflectPlayer player) Console.WriteLine(player.Name);

找不到匹配构造常见 MissingMethodException,构造内部错误常见 TargetInvocationException。只需要编译期可确定的构造时 new Player() 最清晰;泛型工厂可用 where T : new() 避免反射。本章演示 API 的常用重载,不使用依赖远程处理的旧框架专用接口。

9.5 特性:附加元数据

特性是继承 Attribute 的类。把 [标记(...)] 写在声明上会记录元数据;标记本身通常不会“自动运行规则”,必须由编译器、运行时、框架或你的反射代码读取和解释。

1
2
3
4
5
6
7
8
9
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class PluginAttribute : Attribute
{
public string Name { get; }
public int Order { get; set; }
public PluginAttribute(string name) => Name = name;
}
[Plugin("basic", Order = 1)]
public sealed class BasicPlugin { }

名称末尾 Attribute 在使用时可省略。构造参数是位置参数;可写公共属性/字段可作为命名参数。参数类型受限制,常见为基本数值类型、bool、char、string、Type、枚举和符合规则的一维数组,不能随意传 DateTime、decimal 或业务对象。AttributeUsage 控制合法目标、是否允许多次、是否继承。

1
2
PluginAttribute? metadata = typeof(BasicPlugin).GetCustomAttribute<PluginAttribute>();
Console.WriteLine(metadata?.Name);

GetCustomAttribute 会实例化特性;仅检查元数据而不执行特性构造,可使用 CustomAttributeData。游戏在方块定义类上标记 PieceAttribute,再过滤接口实现并用 Activator 创建,最后存入 Dictionary。

9.6 系统自带特性

Obsolete:过时 API

1
2
3
[Obsolete("请使用 NewSave 方法", error: false)]
public static void OldSave() { }
public static void NewSave() { }

false 触发编译警告,true 通常阻止调用代码编译;不会自动转发到新方法。应提供替代方式和迁移说明。

调用者信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using System.Runtime.CompilerServices;

public static void Log(string text,
[CallerMemberName] string member = "",
[CallerFilePath] string file = "",
[CallerLineNumber] int line = 0)
{
Console.WriteLine($"{member}:{line} {text}");
}
public static void Require(bool condition,
[CallerArgumentExpression("condition")] string expression = "")
{
if (!condition) throw new ArgumentException(expression);
}

放在有默认值的参数上,调用者省略该实参时由编译器填入。CallerArgumentExpression 可获得实参表达式文本;不是运行时扫描调用栈。源码路径可能含构建机信息,生产日志应按需处理。

Conditional:条件调用

1
2
3
4
using System.Diagnostics;

[Conditional("DEBUG")]
public static void Trace(string message) => Console.WriteLine(message);

方法需返回 void;调用点没有 DEBUG 符号时,调用及参数求值通常都被省略。因此不要把重要副作用放在日志参数里。该方法仍可出现在程序集,也不能用它代替权限检查。特性声明在被调用方法上,但是否保留调用由调用方的编译符号决定。

DllImport:外部原生 DLL 函数

1
2
3
4
5
6
7
8
9
10
11
12
using System.Runtime.InteropServices;

public static class NativeMethods
{
[DllImport("kernel32.dll", EntryPoint = "GetCurrentProcessId", ExactSpelling = true)]
private static extern uint GetCurrentProcessId();
public static uint CurrentProcessId()
{
if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("Windows only");
return GetCurrentProcessId();
}
}

这是 Windows 原生函数示例,游戏不依赖它。extern 表示实现在外部,DllImport 指明库名、入口和封送约定。现代 .NET 也提供 LibraryImport 源生成方案。字符串字符集、调用约定、整数宽度、布尔布局与资源所有权必须匹配原生签名,否则可能崩溃;失败还可能是位数、库路径或入口不存在。调用托管 C# DLL 则通常添加项目/程序集引用,不用 DllImport。上述功能也可直接用 Environment.ProcessId 获取,P/Invoke 示例用于理解机制。

第十章:迭代器

10.1 可枚举对象与枚举器

IEnumerable<T> 表示“能提供枚举器”;IEnumerator<T> 表示“一次遍历的当前位置状态”。集合可被多次遍历,每次 GetEnumerator 通常创建独立枚举器。

foreach 的概念过程:取得枚举器;反复 MoveNext;成功后读 Current;最后 Dispose。首次 MoveNext 前没有有效当前项,结束后也没有有效当前项。

10.2 手工实现标准迭代器

完整示例(独立类型文件):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using System;
using System.Collections;
using System.Collections.Generic;

public sealed class NumberRange : IEnumerable<int>
{
private readonly int count;
public NumberRange(int count)
{
if (count < 0) throw new ArgumentOutOfRangeException(nameof(count));
this.count = count;
}
public IEnumerator<int> GetEnumerator() => new Cursor(count);
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
private sealed class Cursor : IEnumerator<int>
{
private readonly int count;
private int index = -1;
public Cursor(int count) => this.count = count;
public int Current => index >= 0 && index < count
? index : throw new InvalidOperationException();
object IEnumerator.Current => Current;
public bool MoveNext()
{
if (index < count) index++;
return index < count;
}
public void Reset() => index = -1;
public void Dispose() { }
}
}

foreach (int n in new NumberRange(3)) 输出 0、1、2。Reset 不是 foreach 必需能力,很多框架枚举器直接抛 NotSupportedException。这里支持它便于教学。配套单向链表的枚举器额外检测版本变化,防止在改动的结构上继续遍历。

10.3 yield return 语法糖

1
2
3
4
5
6
7
8
9
10
11
12
public static IEnumerable<int> Range(int count)
{
for (int i = 0; i < count; i++) yield return i;
}
public static IEnumerable<int> UntilNegative(IEnumerable<int> values)
{
foreach (int value in values)
{
if (value < 0) yield break;
yield return value;
}
}

编译器生成状态机,保存执行位置和跨暂停的局部变量。每次 MoveNext 推进到下一次 yield return;yield break 结束。返回 IEnumerable 不等于立即运行完方法,通常遍历才执行主体,异常也可能到遍历时才抛。

如果需要在调用时就验证参数,把验证和迭代主体拆开:

1
2
3
4
5
6
7
8
9
public static IEnumerable<int> CheckedRange(int count)
{
if (count < 0) throw new ArgumentOutOfRangeException(nameof(count));
return Iterate(count);
static IEnumerable<int> Iterate(int n)
{
for (int i = 0; i < n; i++) yield return i;
}
}

迭代器可包含 try/finally,foreach 提前 break 时通常会 Dispose 枚举器,执行清理;不能在 catch/finally 中 yield return,也不能在带 catch 的 try 中 yield return。本文不扩展异步迭代器。

10.4 为泛型类实现迭代器

1
2
3
4
5
6
7
8
9
10
public sealed class Shelf<T> : IEnumerable<T>
{
private readonly List<T> items = new List<T>();
public void Add(T item) => items.Add(item);
public IEnumerator<T> GetEnumerator()
{
foreach (T item in items) yield return item;
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

Shelf<string>、Shelf<int> 复用同一套遍历逻辑。yield 不自动提供快照和线程安全;底层 List 在遍历中修改仍可能抛异常。游戏 Board 用 yield 按行遍历已锁定格子,渲染器不需要知道内部二维数组;活动方块同样逐格产出坐标。

第十一章:特殊语法

11.1 var 隐式局部类型

1
2
3
var count = 3;              // 编译期 int
var name = "Alice"; // string
var map = new Dictionary<string, int>();

var 不是 dynamic,类型推断后不变,不能随后把 count 赋成字符串。局部变量需要能推断类型的初始化表达式;var x = null 不行。它不是成员字段、普通方法参数或返回类型的通用替代。

11.2 对象与集合初始值

1
2
3
4
5
public sealed class Hero
{
public string Name { get; set; } = "";
public int Level { get; set; }
}
1
2
3
4
5
6
7
8
9
10
Hero hero = new Hero { Name = "Alice", Level = 1 };
List<Hero> heroes = new List<Hero> { hero, new Hero { Name = "Bob", Level = 2 } };
Dictionary<string, int> scores = new Dictionary<string, int>
{
{ "Alice", 10 }, { "Bob", 20 } // 调用 Add
};
Dictionary<string, int> otherScores = new Dictionary<string, int>
{
["Alice"] = 10, ["Bob"] = 20 // 索引器赋值
};

先执行构造,再赋初始属性/字段;访问限制依然生效。集合初始值通常依赖 IEnumerable 和可用 Add 方法;字典 Add 形式遇重复键抛异常,索引形式可覆盖前值;同一个初始值列表不要混合这两种形式。C# 12 集合表达式 int[] a = [1,2,3] 属于另一种语法,不应与传统 { ... } 初始值混为一谈。

11.3 匿名类型

1
2
var summary = new { Name = "Alice", Score = 100 };
Console.WriteLine($"{summary.Name}: {summary.Score}");

编译器生成带只读属性的引用类型,适合方法内临时投影。同一程序集、属性名/顺序/类型相同的匿名类型可共享生成类型。不是动态对象,不适合充当稳定公共 API;需要跨模块传递时定义有名类或 record。属性只读不代表其引用对象深度不可变。

11.4 可空类型、空条件、空合并

1
2
3
4
5
6
int? score = null; // Nullable<int>
Console.WriteLine(score.HasValue); // False
int display = score ?? 0;
string? name = null; // 引用类型可空分析标注,不是 Nullable<string>
name ??= "游客";
int length = name?.Length ?? 0;

Nullable 仅用于非可空值类型;Value 在没有值时抛异常。?? 在左侧非 null 时返回左侧,否则求值右侧;??= 仅当左侧为 null 时赋值。?. 遇 null 短路访问链,值类型结果通常提升为可空。空合并不是“空字符串合并”,"" ?? "默认" 仍得到空字符串。后缀 ! 只影响编译器分析,不是运行期 null 检查。

11.5 内插字符串与简写

1
2
3
4
int points = 123;
string label = $"分数:{points:D6}"; // 000123
Console.WriteLine($"{ "Name",-10}{points,8}"); // 对齐
string state = points > 100 ? "高分" : "继续";

数值格式可能受区域设置影响,保存机器数据时可明确使用 InvariantCulture。三元表达式适合简单二选一,深层嵌套会降低可读性。

表达式体成员:public int Double(int x) => x * 2;。它是成员语法,不是 lambda 委托。单条 if/for/while 可省大括号,但嵌套时 else 绑定最近未匹配 if,教学和维护建议保留括号。

1
2
3
4
5
6
7
public static string Describe(int lines) => lines switch
{
0 => "没有消行",
1 => "单行",
2 => "双行",
_ => "多行"
};

switch 表达式必须产出值,_ 常作兜底。逻辑简写应保持语义:bool 结果通常直接 return score > 0;,不必再写 ? true : false。简写不是目标,清楚地表达规则才是。

第十二章:值类型和引用类型二

12.1 如何判断

值类型:基本数值、bool、char、enum、struct、值元组、Nullable。引用类型:class、string、数组、delegate、interface 变量所持有的对象引用。接口本身不是某种固定值布局,值类型转接口通常装箱。

运行时可用 typeof(T).IsValueType 或对象 GetType().IsValueType;后者若来自装箱 int,仍返回 true,因为描述的是被装箱的实际类型。null 没有可调用的 GetType。

判断核心在赋值复制语义,不在“栈还是堆”:值类型变量复制其值,引用类型变量复制引用。值类型也可内嵌于堆对象、数组或被装箱;局部变量也可能被闭包提升,JIT 还可把值放寄存器。

12.2 语句块、作用域和生命周期

作用域是源码中可以按名称访问变量的区域;生命周期是运行时存储/对象持续存在的时间。两者不是同一件事。

1
2
3
4
5
6
Action saved;
{
int count = 0;
saved = () => Console.WriteLine(++count);
}
saved(); saved(); // 1、2,捕获变量在代码块外仍然存在

局部变量在使用前需明确赋值,字段/数组元素通常有默认值。代码块结束不等于对象立即回收;对象无强引用可达后才有资格被 GC 回收,回收时刻不确定。using/Dispose 用于确定性释放资源,不是直接销毁所有托管对象。静态字段通常随加载上下文/进程存活,可能长期保持对象可达。

12.3 结构体中的值和引用

1
2
3
4
5
public struct Bag
{
public int Gold;
public List<string> Items;
}
1
2
3
4
5
6
Bag a = new Bag { Gold = 10, Items = new List<string> { "钥匙" } };
Bag b = a;
b.Gold = 20;
b.Items.Add("药水");
Console.WriteLine(a.Gold); // 10:独立数值字段
Console.WriteLine(a.Items.Count); // 2:两个结构体的字段指向同一个 List

复制结构体会逐字段复制,引用字段只复制引用,不会深拷贝对象。default(Bag) 中 Items 为 null,即使字段被声明为非可空引用,也要考虑结构体默认初始化。可使用不可变结构体和构造验证降低风险。

12.4 类中的值和引用

类对象里可同时有值字段与引用字段;赋值类变量只复制类对象引用,因此通过两个变量看到的是同一份字段。

1
2
3
4
5
public sealed class Wallet
{
public int Gold;
public List<string> Items = new List<string>();
}
1
2
3
4
Wallet a = new Wallet { Gold = 10 };
Wallet b = a;
b.Gold = 20;
Console.WriteLine(a.Gold); // 20

MemberwiseClone() 创建浅复制:新外层对象有独立值字段,但引用字段仍共享目标。深复制需要按业务关系处理整个对象图,尤其要考虑循环和共享关系,不能简单写个通用递归就认为正确。

12.5 数组中的存储规则

数组 槽位内容 新建后默认内容 Clone 结果
int[] int 值 0 独立整数槽位
Bag[] Bag 值,含其引用字段 Gold=0、Items=null 结构体复制,Items 仍可能共享
Wallet[] Wallet 引用 null 新数组仍引用相同 Wallet
int[][] 每行 int[] 的引用 null Clone 只复制外层行引用

数组本身始终是引用类型。new Wallet[3] 只创建三个引用槽位,没有创建三个 Wallet。对 List 索引器返回的结构体不能直接改字段:先取出局部副本,修改后写回;数组元素变量的规则不同,可直接修改其结构体字段。结构体属性返回值也容易出现“改了副本”的问题。

12.6 结构体实现接口与装箱

1
2
3
4
5
6
7
public interface ICounter { void Add(); }
public struct Counter : ICounter
{
public int Value;
public void Add() => Value++;
}
public static void AddOne<T>(ref T value) where T : struct, ICounter => value.Add();
1
2
3
4
5
6
7
Counter counter = new Counter();
ICounter boxed = counter; // 装箱,复制 counter 的值到盒子
boxed.Add();
Console.WriteLine(counter.Value); // 0
Console.WriteLine(((Counter)boxed).Value); // 1
AddOne(ref counter);
Console.WriteLine(counter.Value); // 1

结构体可实现接口,但不能继承自定义类或另一个结构体;它隐式继承 System.ValueType。泛型约束调用通常可避免转换成接口导致的装箱;这里使用 ref 还保证修改原结构体,而不是参数副本。并非所有泛型代码永远不装箱,应根据具体转换和调用分析。

第十三章:项目实践——俄罗斯方块

13.1 项目目标与规则

本项目把前面教程的控制台、数组、循环、结构体、接口、封装继承多态,与本教程的泛型、事件、线程、反射和迭代器连起来。先读规则,再读模型,再读主循环,不要从绘制细节开始。

  • 固定棋盘为 10 列 × 20 行,七种四格方块 I/O/T/S/Z/J/L。
  • 七袋随机:每袋恰有七种方块各一个,用 Fisher–Yates 洗牌后顺序出队。
  • 方块自动下落,左右移动、顺时针旋转、软降、硬降。下落失败立即锁定。
  • 一行全部填满时删除,上方行向下压缩;一次可消 1~4 行。
  • 新方块出生即发生碰撞时结束;开始、游戏、结束分别由场景类管理。
  • 软降每成功移动一格 +1,硬降每格 +2;消 1/2/3/4 行基础分为 100/300/500/800,再乘消行前等级。
  • 每累计 10 行升一级;下落间隔从 600ms 起每级减 45ms,最短 80ms。
  • P 暂停,Esc 回菜单,结束后 R 重开,Enter 回菜单,Esc 退出。

旋转是教学简化规则:在 4×4 模板内旋转,O 不变,依次试水平偏移 0、-1、+1、-2、+2;没有完整 SRS、向上踢墙、隐藏出生行、锁定延迟、Hold、T-Spin 或连击规则。读者应将其视为完整可玩的教学版本,而非竞技规则复刻。

界面 72 列 × 32 行,上方约四分之三为棋盘、下一块预览与状态,下方为最近五条落地/消行日志。游戏没有战斗,因此日志记录消行和操作反馈。活动方块显示 [],固定方块显示字母;边框始终绘制。窗口过小时暂停更新并显示尺寸提示,恢复后不会追赶整段停顿时间。

13.2 文件夹与职责

目录/文件 职责 本章应用
Program.cs 组装依赖、控制台入口、资源边界 using、异常处理
Application/GameApplication.cs 主循环、场景切换 接口依赖、多态
Contracts/ 输入、时钟、渲染、工厂、只读视图协议 泛型协变、接口隔离
Domain/GridPoint.cs 坐标、方块枚举、格子值 只读结构体、运算符重载
Domain/Board.cs 已锁定格子、碰撞、消行 可空二维数组、泛型迭代器
Domain/PieceCatalog.cs 模板、旋转缓存、活动方块 Dictionary、封装、yield
Domain/StatisticsSnapshot.cs 不可变统计数据视图与排名 List、委托排序
Definitions/StandardPieces.cs 七种方块的初始形状 抽象类、继承、多态
Metadata/ 特性、反射工厂 Type、Assembly、Activator、约束
Core/GameEngine.cs 移动、旋转、落地、计分 事件、规则封装
Core/RoundSession.cs 每局对象与订阅生命周期 IDisposable、事件取消
Core/RoundFactory.cs 创建每局新依赖 IFactory
Scenes/ 开始、游戏、结束场景 Scene 抽象类、重写
Infrastructure/ 真实输入、七袋随机、时钟、控制台恢复 Queue、Random、平台适配
Services/StatisticsWorker.cs 后台收集落地统计 Thread、lock、Queue、信号
Presentation/ 菜单与游戏绘制 分部类、StringBuilder、嵌套 Canvas
Diagnostics/GameTrace.cs 调试日志 Conditional、调用者信息
HighExamples/ 章节示例和规则检查 单链表、委托、装箱、自动验证

项目不强行把所有旧集合塞进游戏。生产路径采用类型安全的泛型集合;ArrayList 等在章节示例中演示。单向链表与标准迭代器也保留在独立示例中,不为展示数据结构而增加游戏复杂度。


flowchart TD
  App["GameApplication"] --> Scene["Scene:菜单 / 游戏 / 结束"]
  Scene --> Engine["GameEngine:规则"]
  Scene --> Render["IRenderer:绘制"]
  Engine --> Board["Board + PieceCatalog"]
  Engine --> Stats["PieceLocked → StatisticsWorker"]

13.3 坐标与数据模型

坐标原点在棋盘左上,x 向右,y 向下。cells[y,x] 的第一维是行,与索引器 board[x,y] 参数顺序不同,必须明确。空格用 TetrominoKind? 的 null 表示;enum 的 0 是 I,不应拿 0 当空格。

GridPoint 是只读结构体,提供值相等、GetHashCode、加法和 ==,可用于 HashSet 去重。Board 内部二维数组不向外暴露,渲染层只能通过 IGameView 取得只读状态和枚举序列。

活动方块由 PieceDescriptor、Position、Rotation 组成。移动和旋转先产生候选对象,通过碰撞检查后才替换 active,失败时原方块保持原状。这是“先验证,再提交”的小型状态更新方式,避免移动一半才发现重叠。

旋转公式是 (x,y) → (3-y,x)。四次旋转应回到原始格子集合;O 特判不变。这个公式围绕模板中心旋转,故 I、J 等的视觉位置会与完整 SRS 不同。

13.4 碰撞、下落与锁定

Board.CanPlace 对四个格子逐个检查边界和是否被占据,必须先判断边界,再读取数组,利用短路避免越界。

1
2
3
foreach (GridPoint p in piece.Cells)
if (!Contains(p) || cells[p.Y, p.X].HasValue) return false;
return true;

DropOne 尝试向下移动,成功可奖励软降分;失败调用 LockActive。HardDrop 反复 TryMove 到最后合法位置,再统一锁定一次。结束后所有移动返回或无效果,避免重复落地、重复加分。

LockActive 的顺序是:写入棋盘 → 消行 → 按旧等级计算奖励 → 更新行数和分数 → 生成下一块并检查结束 → 发布落地事件。事件处理器能观察到完整的新状态;外部事件处理器不应直接改动引擎内部状态。

13.5 消行压缩算法

使用 readRow 与 writeRow 从底部向上扫描:满行跳过,不满行复制到 writeRow,再让 writeRow 上移。最后把顶部剩余行清空。单次消行成本 O(行数×列数),不需要反复移动整个棋盘。

1
2
3
4
5
6
7
8
9
10
11
12
int writeRow = Rows - 1;
for (int readRow = Rows - 1; readRow >= 0; readRow--)
{
bool full = true;
for (int x = 0; x < Columns; x++)
if (!cells[readRow, x].HasValue) { full = false; break; }
if (full) continue;
for (int x = 0; x < Columns; x++) cells[writeRow, x] = cells[readRow, x];
writeRow--;
}
for (; writeRow >= 0; writeRow--)
for (int x = 0; x < Columns; x++) cells[writeRow, x] = null;

这段解释版省略 cleared 计数,源码保留完整返回值。连续满行和中间隔着非满行都应正确;从上往下逐行删除容易漏行,因此配套检查覆盖所有四行满/不满组合。

13.6 主循环与场景生命周期

主循环每轮:检查窗口 → 读取有限数量按键 → 计算经过时间 → 更新场景 → 绘制 → 小幅休眠。时间用 Stopwatch,不能用“每循环一次就下移一格”,否则不同机器速度不同。

场景有 Enter、Handle、Update、Render、Exit。开始场景只处理开始/退出;游戏场景控制暂停和落地日志;结束场景展示得分、行数、方块排名。切换时旧场景先 Exit,新场景再 Enter,订阅和清理均有对称位置。

PlayScene 累积时间,达到 FallMilliseconds 就 Tick;暂停时不积累时间,恢复时不瞬间下落多格。硬降后重置下落计时,避免新方块刚生成就吃掉旧方块剩余的时间。

13.7 事件与统计线程

PieceLockedEventArgs 仅保存枚举和整数,只读属性使数据交给后台后不再变化。StatisticsWorker 在 lock 内入队并发信号;后台从队列取出事件,用 Dictionary 更新计数;Capture 在锁内复制快照。

AutoResetEvent 的信号可能合并,所以它只表示“可能有工作”,事件本身必须留在 Queue 中。消费者醒来后持续处理,队空才再等待。不能把信号次数当成事件次数。

退出顺序:取消游戏事件订阅 → 标记 stopping 并发信号 → 不持锁 Join → 工作线程处理完已入队事件后返回 → 释放信号。结束场景读取 Join 之后的快照,保证最终计数不落后。正常游戏帧里的统计可能短暂落后于画面,这是异步统计的预期表现。

13.8 反射注册方块

每个方块定义实现 IPieceDefinition,并标记 [Piece(TetrominoKind.T,"T")]。启动时扫描明确指定的自身程序集,过滤非抽象类和接口实现,再读取特性、检查公开无参构造、Activator.CreateInstance,最后建立字典。

这是本教程的“泛型 + 约束 + 反射 + 特性”的组合案例:CreateAll<TContract,TAttribute> 用 class 和 Attribute 约束保证基本能力;IsAssignableFrom 进行运行时筛选;注册完成后正常游戏走接口和缓存,不每帧反射。

新增方块还需同步枚举与规则假设;代码仅保证七种标准方块,并不承诺“随便增加任意形状就完全兼容”。结构扩展需要一并检查随机袋、棋盘、计分与渲染范围。

13.9 运行与发布

1
2
3
dotnet build TetrisGame/TetrisGame.csproj -c Release
dotnet run --project TetrisGame/TetrisGame.csproj -c Release
dotnet run --project HighExamples/HighExamples.csproj -- --checks-only

框架依赖发布:

1
dotnet publish TetrisGame/TetrisGame.csproj -c Release -o publish

产物仍需要目标机器的兼容 .NET 运行时;自包含发布需要指定目标系统运行标识,不能把 Windows 产物直接当成所有系统通用程序。源码包不包含 SDK、bin、obj 或第三方组件。

13.10 自测流程与项目局限

先按 Enter 开始,左右移动至边界并旋转;按 P 后观察方块停住,等待几秒再恢复;按 Space 多次堆叠到出生区触发结束;按 R 确认分数、棋盘和统计都重置;Esc 回菜单再退出。缩小终端后应出现提示,调大后继续。

自动检查用固定随机种子覆盖七袋预览与出队、四次旋转、边界、棋盘复制隔离、消行顺序、精确计分、出生碰撞、游戏结束后无额外操作、暂停及统计清空。随机模拟用于发现状态不变量问题,不能替代所有人工可玩性评估。

Console API 在不同终端上的按键重复速度与刷新效果可能不同;动态调整窗口恰逢绘制时程序会提示重新启动。反射扫描依赖元数据,本文未启用裁剪或 Native AOT;若以后启用,需为反射目标显式保留元数据或改为生成注册表。

练习与参考答案

章节 练习 参考答案要点
1 为什么不能把装箱 int 直接拆成 long? 拆箱必须匹配盒中值类型,先拆 int 再数值转换
2 编写能创建对象且调用 Id 的工厂 where T : IHasId, new();接口提供 Id,new() 提供构造能力
3 删除单链表最后一个节点要更新什么? 前驱 Next=null、tail=前驱;唯一节点时 head/tail 均 null;Count/version 更新
4 为什么事件处理器要取消订阅? 发布者可能持有订阅者引用,导致生命周期延长或旧场景重复响应
5 给同分玩家一个确定顺序 比分数,再用姓名或唯一 id;不用减法比较整数
6 为什么 List 不能给 List? Animal 列表允许加 Cat,会破坏 Dog 列表类型安全;只读生产接口可协变
7 为什么 Sleep 不能保证工作已完成? 调度时间不确定,也没有完成状态或内存同步协议;用 Join/信号/Task
8 Release 中被 Conditional 省略的日志参数会执行吗? 未保留调用时通常不会,因此不能依赖参数副作用
9 特性为何不会自动调用业务代码? 它通常只保存元数据,需要消费者读取解释
10 yield 方法中的异常为什么可能晚发生? 方法体通过状态机在 MoveNext 时执行;可拆验证与迭代主体
11 var 与 dynamic 的区别? var 编译期静态推断,dynamic 把部分绑定延迟到运行时
12 复制含 List 字段的结构体为何会共享内容? 结构体字段复制,List 字段复制的是引用
项目 为什么需要在 Join 后读取结束统计? 后台可能尚未消费完队列,Join 确认退出并完成积压工作

建议扩展:增加按键设置、最高分持久化、幽灵落点、完整 SRS、Hold。每次只新增一项规则,并先为状态边界写检查。若新增存档线程,继续保持棋盘由主线程独占,把不可变快照交给后台。

官方资料与阅读索引

以下为 Microsoft Learn 官方资料,查阅于 2026-10-28。本文解释与示例以固定的 C# 12 语法为主,API 以配套目标框架为准;页面可能包含更新版本内容。

验证说明

本次交付中,游戏 Debug/Release 与章节示例通过编译,游戏构建零警告零错误。规则程序通过 8,589 项断言;正文 57 段 C# 代码在补充所需 using、类型声明或方法体上下文后编译通过,var 示例有两条未使用变量警告。终端按键测试跑通开始、暂停、恢复、结束、重开、菜单和退出。源码包的 verification/验证记录.md 说明检查范围与限制,Windows 原生 DLL 示例仅做编译验证。

附录:完整俄罗斯方块源码

以下按相对路径列出游戏全部源码与项目文件。可直接使用压缩包;手动重建时按路径保存。HighExamples 的完整章节示例、单链表和规则检查位于源码包中。

TetrisGame/Application/GameApplication.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
using System;
using TetrisGame.Contracts;
using TetrisGame.Scenes;

namespace TetrisGame.Application
{
public sealed class GameApplication : ISceneNavigator
{
private readonly IInputSource input;
private readonly IRenderer renderer;
private readonly IGameClock clock;
private readonly IViewport viewport;
private readonly IFactory<TetrisGame.Core.RoundSession> factory;
private Scene scene;
private bool running = true;

public GameApplication(IInputSource input, IRenderer renderer, IGameClock clock,
IViewport viewport, IFactory<TetrisGame.Core.RoundSession> factory)
{
this.input = input ?? throw new ArgumentNullException(nameof(input));
this.renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.viewport = viewport ?? throw new ArgumentNullException(nameof(viewport));
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
scene = new StartScene(this, renderer);
}

public void Run()
{
scene.Enter();
try
{
TimeSpan last = clock.Elapsed;
while (running)
{
if (!viewport.IsReady)
{
viewport.ShowResizeHint();
if (input.TryRead(out InputCommand smallWindowCommand) && smallWindowCommand == InputCommand.Escape)
Quit();
input.ClearPending();
clock.Sleep(50);
last = clock.Elapsed;
continue;
}

for (int count = 0; count < 8 && input.TryRead(out InputCommand command); count++)
{
Scene previous = scene;
scene.Handle(command);
if (!running || !ReferenceEquals(previous, scene))
{
input.ClearPending();
last = clock.Elapsed;
break;
}
}
if (!running)
break;
TimeSpan now = clock.Elapsed;
TimeSpan elapsed = now - last;
last = now;
// 长时间停顿后限制追赶量,避免恢复显示时瞬间走出很多步。
if (elapsed > TimeSpan.FromMilliseconds(250))
elapsed = TimeSpan.FromMilliseconds(250);
scene.Update(elapsed);
scene.Render();
clock.Sleep(16);
}
}
finally { scene.Exit(); }
}

private void ChangeScene(Scene next)
{
scene.Exit();
scene = next;
scene.Enter();
}

public void StartRound() => ChangeScene(new PlayScene(this, renderer, factory.Create()));
public void ShowMenu() => ChangeScene(new StartScene(this, renderer));
public void EndRound(TetrisGame.Core.RoundSummary result) => ChangeScene(new EndScene(this, renderer, result));
public void Quit() => running = false;
}
}

TetrisGame/Contracts/IGameView.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using System.Collections.Generic;
using TetrisGame.Domain;

namespace TetrisGame.Contracts
{
public interface IGameView
{
int Columns { get; }
int Rows { get; }
int Score { get; }
int Lines { get; }
int Level { get; }
bool IsGameOver { get; }
TetrominoKind NextKind { get; }
IEnumerable<BoardCell> LockedCells { get; }
IEnumerable<GridPoint> ActiveCells { get; }
}
}

TetrisGame/Contracts/PieceContracts.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System.Collections.Generic;
using TetrisGame.Domain;

namespace TetrisGame.Contracts
{
public interface IPieceDefinition
{
TetrominoKind Kind { get; }
GridPoint[] CreateCells();
}

public interface IPieceSource
{
TetrominoKind Next();
IReadOnlyList<TetrominoKind> Preview(int count);
}

public interface IFactory<out T> { T Create(); }
}

TetrisGame/Contracts/PlatformContracts.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System;
using System.Collections.Generic;
using TetrisGame.Core;
using TetrisGame.Domain;

namespace TetrisGame.Contracts
{
public enum InputCommand { None, Start, Left, Right, Rotate, SoftDrop, HardDrop, Pause, Replay, Escape }
public interface IInputSource { bool TryRead(out InputCommand command); void ClearPending(); }
public interface IGameClock { TimeSpan Elapsed { get; } void Sleep(int milliseconds); }
public interface IViewport { bool IsReady { get; } void ShowResizeHint(); }
public interface IMenuRenderer { void DrawStart(); void DrawEnd(RoundSummary result); }
public interface IGameRenderer { void DrawGame(IGameView game, bool paused, IReadOnlyList<string> messages, StatisticsSnapshot stats); }
public interface IRenderer : IMenuRenderer, IGameRenderer { }
public interface ISceneNavigator { void StartRound(); void ShowMenu(); void EndRound(RoundSummary summary); void Quit(); }
}

TetrisGame/Core/GameEngine.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
using System;
using System.Collections.Generic;
using TetrisGame.Contracts;
using TetrisGame.Domain;

namespace TetrisGame.Core
{
public sealed class PieceLockedEventArgs : EventArgs
{
public TetrominoKind Kind { get; }
public int ClearedLines { get; }
public int AwardedPoints { get; }
public PieceLockedEventArgs(TetrominoKind kind, int lines, int points)
{ Kind = kind; ClearedLines = lines; AwardedPoints = points; }
}

public sealed class GameEngine : IGameView
{
private readonly Board board;
private readonly PieceCatalog catalog;
private readonly IPieceSource source;
private FallingPiece active;
public event EventHandler<PieceLockedEventArgs>? PieceLocked;
public int Columns => board.Columns;
public int Rows => board.Rows;
public int Score { get; private set; }
public int Lines { get; private set; }
public int Level => 1 + Lines / 10;
public int FallMilliseconds => Math.Max(80, 600 - (Level - 1) * 45);
public bool IsGameOver { get; private set; }
public TetrominoKind NextKind => source.Preview(1)[0];
public IEnumerable<GridPoint> ActiveCells => active.Cells;
public IEnumerable<BoardCell> LockedCells
{ get { foreach (BoardCell cell in board) yield return cell; } }

public GameEngine(Board board, PieceCatalog catalog, IPieceSource source)
{
ArgumentNullException.ThrowIfNull(board);
this.board = new Board(board.Columns, board.Rows, board);
this.catalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
this.source = source ?? throw new ArgumentNullException(nameof(source));
active = Spawn();
}
private FallingPiece Spawn()
{
FallingPiece piece = new FallingPiece(catalog[source.Next()], new GridPoint(Columns / 2 - 2, 0));
if (!board.CanPlace(piece)) IsGameOver = true;
return piece;
}
public bool MoveHorizontal(int dx)
{
if (dx != -1 && dx != 1) throw new ArgumentOutOfRangeException(nameof(dx));
return TryMove(dx, 0);
}
private bool TryMove(int dx, int dy)
{
if (IsGameOver) return false;
FallingPiece candidate = active.Move(dx, dy);
if (!board.CanPlace(candidate)) return false;
active = candidate;
return true;
}
public bool Rotate()
{
if (IsGameOver) return false;
FallingPiece rotated = active.Rotate();
foreach (int dx in new int[] { 0, -1, 1, -2, 2 })
{
FallingPiece candidate = rotated.Move(dx, 0);
if (!board.CanPlace(candidate)) continue;
active = candidate; return true;
}
return false;
}
public void Tick() => DropOne(awardPoint: false);
public void SoftDrop() => DropOne(awardPoint: true);
private void DropOne(bool awardPoint)
{
if (IsGameOver) return;
if (TryMove(0, 1)) { if (awardPoint) Score++; }
else LockActive();
}
public void HardDrop()
{
if (IsGameOver) return;
int distance = 0;
while (TryMove(0, 1)) distance++;
Score += distance * 2;
LockActive();
}
private void LockActive()
{
TetrominoKind kind = active.Kind;
board.Lock(active);
int cleared = board.ClearFullRows();
int basePoints = cleared switch { 0 => 0, 1 => 100, 2 => 300, 3 => 500, 4 => 800, _ => throw new InvalidOperationException("Too many full rows.") };
int points = basePoints * Level;
Lines += cleared;
Score += points;
active = Spawn();
PieceLocked?.Invoke(this, new PieceLockedEventArgs(kind, cleared, points));
}
}
}

TetrisGame/Core/RoundFactory.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using System;
using TetrisGame.Contracts;
using TetrisGame.Domain;
using TetrisGame.Infrastructure;

namespace TetrisGame.Core
{
public sealed class RoundFactory : IFactory<RoundSession>
{
private readonly PieceCatalog catalog;
public RoundFactory(PieceCatalog catalog) => this.catalog = catalog;
public RoundSession Create() => new RoundSession(new GameEngine(new Board(10, 20), catalog, new SevenBagSource(new Random())));
}
}

TetrisGame/Core/RoundSession.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
using System;
using TetrisGame.Domain;
using TetrisGame.Services;

namespace TetrisGame.Core
{
public sealed class RoundSession : IDisposable
{
private bool disposed;
public GameEngine Game { get; }
public StatisticsWorker Statistics { get; }
public RoundSession(GameEngine game)
{
Game = game ?? throw new ArgumentNullException(nameof(game));
Statistics = new StatisticsWorker();
Game.PieceLocked += Statistics.OnPieceLocked;
}
public void Dispose()
{
if (disposed) return;
disposed = true;
Game.PieceLocked -= Statistics.OnPieceLocked;
Statistics.Dispose();
}
}

public sealed class RoundSummary
{
public int Score { get; }
public int Lines { get; }
public int Level { get; }
public StatisticsSnapshot Statistics { get; }
public RoundSummary(RoundSession session)
{
session.Dispose();
Score = session.Game.Score; Lines = session.Game.Lines; Level = session.Game.Level;
Statistics = session.Statistics.Capture();
}
}
}

TetrisGame/Definitions/StandardPieces.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
using System.Collections.Generic;
using TetrisGame.Contracts;
using TetrisGame.Domain;
using TetrisGame.Metadata;

namespace TetrisGame.Definitions
{
public abstract class StandardPiece : IPieceDefinition
{
public abstract TetrominoKind Kind { get; }
public abstract GridPoint[] CreateCells();
protected static GridPoint[] Parse(params string[] rows)
{
List<GridPoint> cells = new List<GridPoint>();
for (int y = 0; y < rows.Length; y++)
for (int x = 0; x < rows[y].Length; x++)
if (rows[y][x] == '#') cells.Add(new GridPoint(x, y));
return cells.ToArray();
}
}
[Piece(TetrominoKind.I, "I")]
public sealed class IPiece : StandardPiece
{ public override TetrominoKind Kind => TetrominoKind.I; public override GridPoint[] CreateCells() => Parse("....", "####", "....", "...."); }
[Piece(TetrominoKind.O, "O")]
public sealed class OPiece : StandardPiece
{ public override TetrominoKind Kind => TetrominoKind.O; public override GridPoint[] CreateCells() => Parse(".##.", ".##.", "....", "...."); }
[Piece(TetrominoKind.T, "T")]
public sealed class TPiece : StandardPiece
{ public override TetrominoKind Kind => TetrominoKind.T; public override GridPoint[] CreateCells() => Parse(".#..", "###.", "....", "...."); }
[Piece(TetrominoKind.S, "S")]
public sealed class SPiece : StandardPiece
{ public override TetrominoKind Kind => TetrominoKind.S; public override GridPoint[] CreateCells() => Parse(".##.", "##..", "....", "...."); }
[Piece(TetrominoKind.Z, "Z")]
public sealed class ZPiece : StandardPiece
{ public override TetrominoKind Kind => TetrominoKind.Z; public override GridPoint[] CreateCells() => Parse("##..", ".##.", "....", "...."); }
[Piece(TetrominoKind.J, "J")]
public sealed class JPiece : StandardPiece
{ public override TetrominoKind Kind => TetrominoKind.J; public override GridPoint[] CreateCells() => Parse("#...", "###.", "....", "...."); }
[Piece(TetrominoKind.L, "L")]
public sealed class LPiece : StandardPiece
{ public override TetrominoKind Kind => TetrominoKind.L; public override GridPoint[] CreateCells() => Parse("..#.", "###.", "....", "...."); }
}

TetrisGame/Diagnostics/GameTrace.cs

1
2
3
4
5
6
7
8
9
10
11
12
using System.Diagnostics;
using System.Runtime.CompilerServices;

namespace TetrisGame.Diagnostics
{
public static class GameTrace
{
[Conditional("DEBUG")]
public static void Write(string message, [CallerMemberName] string member = "", [CallerLineNumber] int line = 0)
=> Debug.WriteLine($"{member}:{line} {message}");
}
}

TetrisGame/Domain/Board.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
using System;
using System.Collections;
using System.Collections.Generic;

namespace TetrisGame.Domain
{
public sealed class Board : IEnumerable<BoardCell>
{
private readonly TetrominoKind?[,] cells;
public int Columns { get; }
public int Rows { get; }
public Board(int columns, int rows, IEnumerable<BoardCell>? initial = null)
{
if (columns < 4 || rows < 4) throw new ArgumentOutOfRangeException(nameof(columns));
Columns = columns; Rows = rows; cells = new TetrominoKind?[rows, columns];
if (initial is not null)
foreach (BoardCell cell in initial)
{
GridPoint p = cell.Position;
if (!Contains(p) || cells[p.Y, p.X].HasValue) throw new ArgumentException("Invalid initial cell.", nameof(initial));
cells[p.Y, p.X] = cell.Kind;
}
}
public TetrominoKind? this[int x, int y] => cells[y, x];
public bool Contains(GridPoint p) => p.X >= 0 && p.X < Columns && p.Y >= 0 && p.Y < Rows;
public bool CanPlace(FallingPiece piece)
{
foreach (GridPoint p in piece.Cells)
if (!Contains(p) || cells[p.Y, p.X].HasValue) return false;
return true;
}
public void Lock(FallingPiece piece)
{
if (!CanPlace(piece)) throw new InvalidOperationException("Cannot lock overlapping or out-of-board cells.");
foreach (GridPoint p in piece.Cells) cells[p.Y, p.X] = piece.Kind;
}
public int ClearFullRows()
{
int writeRow = Rows - 1, cleared = 0;
for (int readRow = Rows - 1; readRow >= 0; readRow--)
{
bool full = true;
for (int x = 0; x < Columns; x++) if (!cells[readRow, x].HasValue) { full = false; break; }
if (full) { cleared++; continue; }
for (int x = 0; x < Columns; x++) cells[writeRow, x] = cells[readRow, x];
writeRow--;
}
for (; writeRow >= 0; writeRow--)
for (int x = 0; x < Columns; x++) cells[writeRow, x] = null;
return cleared;
}
public IEnumerator<BoardCell> GetEnumerator()
{
for (int y = 0; y < Rows; y++)
for (int x = 0; x < Columns; x++)
if (cells[y, x] is TetrominoKind kind) yield return new BoardCell(new GridPoint(x, y), kind);
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}

TetrisGame/Domain/GridPoint.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
using System;

namespace TetrisGame.Domain
{
public readonly struct GridPoint : IEquatable<GridPoint>
{
public int X { get; }
public int Y { get; }
public GridPoint(int x, int y) { X = x; Y = y; }
public static GridPoint operator +(GridPoint a, GridPoint b) => new GridPoint(a.X + b.X, a.Y + b.Y);
public bool Equals(GridPoint other) => X == other.X && Y == other.Y;
public override bool Equals(object? value) => value is GridPoint other && Equals(other);
public override int GetHashCode() => HashCode.Combine(X, Y);
public static bool operator ==(GridPoint a, GridPoint b) => a.Equals(b);
public static bool operator !=(GridPoint a, GridPoint b) => !a.Equals(b);
public override string ToString() => $"({X}, {Y})";
}

public enum TetrominoKind { I, O, T, S, Z, J, L }

public readonly struct BoardCell
{
public GridPoint Position { get; }
public TetrominoKind Kind { get; }
public BoardCell(GridPoint position, TetrominoKind kind) { Position = position; Kind = kind; }
}
}

TetrisGame/Domain/PieceCatalog.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
using System;
using System.Collections.Generic;
using TetrisGame.Contracts;
using TetrisGame.Metadata;

namespace TetrisGame.Domain
{
public sealed class PieceDescriptor
{
private readonly GridPoint[][] rotations = new GridPoint[4][];
public TetrominoKind Kind { get; }
public string Name { get; }
public PieceDescriptor(TetrominoKind kind, string name, GridPoint[] cells)
{
ArgumentNullException.ThrowIfNull(cells);
if (cells.Length != 4) throw new ArgumentException("A tetromino needs four cells.", nameof(cells));
for (int i = 0; i < cells.Length; i++)
{
if (cells[i].X < 0 || cells[i].X > 3 || cells[i].Y < 0 || cells[i].Y > 3)
throw new ArgumentException("Cells must fit in a 4 x 4 template.", nameof(cells));
for (int j = 0; j < i; j++)
if (cells[i] == cells[j]) throw new ArgumentException("Duplicate template cell.", nameof(cells));
}
Kind = kind; Name = name; rotations[0] = (GridPoint[])cells.Clone();
for (int turn = 1; turn < 4; turn++)
{
rotations[turn] = new GridPoint[4];
for (int i = 0; i < 4; i++)
{
GridPoint previous = rotations[turn - 1][i];
rotations[turn][i] = kind == TetrominoKind.O ? previous : new GridPoint(3 - previous.Y, previous.X);
}
}
}
public IEnumerable<GridPoint> Offsets(int rotation)
{
if (rotation < 0 || rotation > 3) throw new ArgumentOutOfRangeException(nameof(rotation));
foreach (GridPoint point in rotations[rotation]) yield return point;
}
}

public sealed class PieceCatalog
{
private readonly Dictionary<TetrominoKind, PieceDescriptor> definitions = new Dictionary<TetrominoKind, PieceDescriptor>();
public PieceDescriptor this[TetrominoKind kind] => definitions[kind];
public int Count => definitions.Count;
public PieceCatalog()
{
foreach (Registration<IPieceDefinition, PieceAttribute> item in
ReflectionRegistry.CreateAll<IPieceDefinition, PieceAttribute>(typeof(PieceCatalog).Assembly))
{
if (item.Instance.Kind != item.Metadata.Kind) throw new InvalidOperationException("Piece metadata mismatch.");
definitions.Add(item.Metadata.Kind, new PieceDescriptor(item.Metadata.Kind, item.Metadata.Name, item.Instance.CreateCells()));
}
foreach (TetrominoKind kind in Enum.GetValues<TetrominoKind>())
if (!definitions.ContainsKey(kind)) throw new InvalidOperationException($"Missing piece {kind}.");
}
}

public sealed class FallingPiece
{
private readonly PieceDescriptor descriptor;
public TetrominoKind Kind => descriptor.Kind;
public GridPoint Position { get; }
public int Rotation { get; }
public FallingPiece(PieceDescriptor descriptor, GridPoint position, int rotation = 0)
{
this.descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor));
if (rotation < 0 || rotation > 3) throw new ArgumentOutOfRangeException(nameof(rotation));
Position = position; Rotation = rotation;
}
public IEnumerable<GridPoint> Cells
{ get { foreach (GridPoint offset in descriptor.Offsets(Rotation)) yield return Position + offset; } }
public FallingPiece Move(int dx, int dy) => new FallingPiece(descriptor, Position + new GridPoint(dx, dy), Rotation);
public FallingPiece Rotate() => new FallingPiece(descriptor, Position, (Rotation + 1) % 4);
}
}

TetrisGame/Domain/StatisticsSnapshot.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
using System;
using System.Collections.Generic;

namespace TetrisGame.Domain
{
public readonly struct PieceCount
{
public TetrominoKind Kind { get; }
public int Count { get; }
public PieceCount(TetrominoKind kind, int count) { Kind = kind; Count = count; }
}

public sealed class StatisticsSnapshot
{
private readonly List<PieceCount> counts;
public long Pieces { get; }
public long Lines { get; }
public long ClearPoints { get; }
public StatisticsSnapshot(long pieces, long lines, long points, IEnumerable<PieceCount> counts)
{ Pieces = pieces; Lines = lines; ClearPoints = points; this.counts = new List<PieceCount>(counts); }

public IReadOnlyList<PieceCount> GetRanking()
{
List<PieceCount> ranking = new List<PieceCount>(counts);
ranking.Sort((a, b) =>
{
int byCount = b.Count.CompareTo(a.Count);
return byCount != 0 ? byCount : a.Kind.CompareTo(b.Kind);
});
return ranking.AsReadOnly();
}
}
}

TetrisGame/Infrastructure/ConsoleInputSource.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System;
using TetrisGame.Contracts;

namespace TetrisGame.Infrastructure
{
public sealed class ConsoleInputSource : IInputSource
{
public bool TryRead(out InputCommand command)
{
command = InputCommand.None;
if (!Console.KeyAvailable) return false;
switch (Console.ReadKey(true).Key)
{
case ConsoleKey.Enter: command = InputCommand.Start; break;
case ConsoleKey.A:
case ConsoleKey.LeftArrow: command = InputCommand.Left; break;
case ConsoleKey.D:
case ConsoleKey.RightArrow: command = InputCommand.Right; break;
case ConsoleKey.W:
case ConsoleKey.UpArrow: command = InputCommand.Rotate; break;
case ConsoleKey.S:
case ConsoleKey.DownArrow: command = InputCommand.SoftDrop; break;
case ConsoleKey.Spacebar: command = InputCommand.HardDrop; break;
case ConsoleKey.P: command = InputCommand.Pause; break;
case ConsoleKey.R: command = InputCommand.Replay; break;
case ConsoleKey.Escape: command = InputCommand.Escape; break;
}
return true;
}
public void ClearPending() { for (int i = 0; i < 64 && Console.KeyAvailable; i++) Console.ReadKey(true); }
}
}

TetrisGame/Infrastructure/ConsoleSession.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
using System;

namespace TetrisGame.Infrastructure
{
public sealed class ConsoleSession : IDisposable
{
private bool disposed;

public ConsoleSession()
{
try
{
Console.CursorVisible = false;
Console.Clear();
}
catch
{
Console.CursorVisible = true;
throw;
}
}

public void Dispose()
{
if (disposed)
return;
disposed = true;
Console.CursorVisible = true;
Console.ResetColor();
Console.WriteLine();
}
}
}

TetrisGame/Infrastructure/SevenBagSource.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using System;
using System.Collections.Generic;
using TetrisGame.Contracts;
using TetrisGame.Domain;

namespace TetrisGame.Infrastructure
{
public sealed class SevenBagSource : IPieceSource
{
private readonly Queue<TetrominoKind> queue = new Queue<TetrominoKind>();
private readonly Random random;
public SevenBagSource(Random random) => this.random = random ?? throw new ArgumentNullException(nameof(random));
private void Ensure(int count)
{
while (queue.Count < count)
{
TetrominoKind[] bag = Enum.GetValues<TetrominoKind>();
for (int i = bag.Length - 1; i > 0; i--)
{
int j = random.Next(i + 1);
(bag[i], bag[j]) = (bag[j], bag[i]);
}
foreach (TetrominoKind kind in bag) queue.Enqueue(kind);
}
}
public TetrominoKind Next() { Ensure(1); return queue.Dequeue(); }
public IReadOnlyList<TetrominoKind> Preview(int count)
{
if (count < 0) throw new ArgumentOutOfRangeException(nameof(count));
Ensure(count);
TetrominoKind[] result = new TetrominoKind[count];
int index = 0;
foreach (TetrominoKind kind in queue) { if (index == count) break; result[index++] = kind; }
return Array.AsReadOnly(result);
}
}
}

TetrisGame/Infrastructure/SystemGameClock.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using System;
using System.Diagnostics;
using System.Threading;
using TetrisGame.Contracts;

namespace TetrisGame.Infrastructure
{
public sealed class SystemGameClock : IGameClock
{
private readonly Stopwatch stopwatch = Stopwatch.StartNew();
public TimeSpan Elapsed => stopwatch.Elapsed;
public void Sleep(int milliseconds) => Thread.Sleep(milliseconds);
}
}

TetrisGame/Metadata/PieceAttribute.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
using System;
using TetrisGame.Domain;

namespace TetrisGame.Metadata
{
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class PieceAttribute : Attribute
{
public TetrominoKind Kind { get; }
public string Name { get; }
public PieceAttribute(TetrominoKind kind, string name) { Kind = kind; Name = name; }
}
}

TetrisGame/Metadata/ReflectionRegistry.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
using System;
using System.Collections.Generic;
using System.Reflection;

namespace TetrisGame.Metadata
{
public sealed class Registration<TContract, TAttribute>
where TContract : class where TAttribute : Attribute
{
public TContract Instance { get; }
public TAttribute Metadata { get; }
public Registration(TContract instance, TAttribute metadata) { Instance = instance; Metadata = metadata; }
}

public static class ReflectionRegistry
{
// 只扫描调用者明确传入的可信程序集,不遍历任意外部 DLL。
public static IReadOnlyList<Registration<TContract, TAttribute>> CreateAll<TContract, TAttribute>(Assembly assembly)
where TContract : class where TAttribute : Attribute
{
ArgumentNullException.ThrowIfNull(assembly);
List<Registration<TContract, TAttribute>> result = new List<Registration<TContract, TAttribute>>();
foreach (Type type in assembly.GetTypes())
{
if (!type.IsClass || type.IsAbstract || type.ContainsGenericParameters || !typeof(TContract).IsAssignableFrom(type))
continue;
TAttribute? metadata = type.GetCustomAttribute<TAttribute>(inherit: false);
if (metadata is null)
continue;
if (type.GetConstructor(Type.EmptyTypes) is null)
throw new InvalidOperationException($"{type.Name} needs a public parameterless constructor.");
TContract instance = (TContract)(Activator.CreateInstance(type)
?? throw new InvalidOperationException("Constructor returned null."));
result.Add(new Registration<TContract, TAttribute>(instance, metadata));
}
return result.AsReadOnly();
}
}
}

TetrisGame/Presentation/ConsoleRenderer.Menus.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using System.Text;
using TetrisGame.Core;
using TetrisGame.Domain;

namespace TetrisGame.Presentation
{
public sealed partial class ConsoleRenderer
{
public void DrawStart() => Present(BuildStart());
public void DrawEnd(RoundSummary result) => Present(BuildEnd(result));
private static Canvas BuildStart()
{
Canvas c = new Canvas();
c.Put(25, 5, "TETRIS / C# HIGH LEVEL");
c.Put(6, 9, "Seven pieces. Clear complete rows to score.");
c.Put(6, 12, "A/D or arrows: move. W/Up: rotate. S/Down: soft drop.");
c.Put(6, 15, "SPACE: hard drop. P: pause/resume. ESC: menu.");
c.Put(6, 18, "Simple clockwise rotation with horizontal wall checks.");
c.Put(6, 22, "ENTER: start ESC: quit");
return c;
}
private static Canvas BuildEnd(RoundSummary result)
{
Canvas c = new Canvas();
c.Put(28, 5, "GAME OVER");
c.Put(6, 10, $"Score: {result.Score} Lines: {result.Lines} Level: {result.Level}");
c.Put(6, 13, $"Placed pieces: {result.Statistics.Pieces}");
StringBuilder ranking = new StringBuilder("Most used: ");
foreach (PieceCount item in result.Statistics.GetRanking())
ranking.Append(item.Kind).Append('=').Append(item.Count).Append(' ');
c.Put(6, 16, ranking.ToString());
c.Put(6, 21, "R: play again ENTER: main menu ESC: quit");
return c;
}
}
}

TetrisGame/Presentation/ConsoleRenderer.Play.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
using System;
using System.Collections.Generic;
using TetrisGame.Contracts;
using TetrisGame.Domain;

namespace TetrisGame.Presentation
{
public sealed partial class ConsoleRenderer
{
private readonly PieceCatalog catalog;
public ConsoleRenderer(PieceCatalog catalog) => this.catalog = catalog;
public void DrawGame(IGameView game, bool paused, IReadOnlyList<string> messages, StatisticsSnapshot stats)
=> Present(BuildGame(game, paused, messages, stats));
private Canvas BuildGame(IGameView game, bool paused, IReadOnlyList<string> messages, StatisticsSnapshot stats)
{
if (game.Columns > 10 || game.Rows > 20) throw new ArgumentException("Renderer supports up to 10 x 20 cells.");
Canvas c = new Canvas();
c.Line(2); c.Line(23); c.Line(24);
c.Put(1, 1, $" TETRIS Score:{game.Score} Lines:{game.Lines} Level:{game.Level} {(paused ? "PAUSED" : "RUNNING")}");
c.Box(3, 2, game.Columns * 2 + 2, game.Rows + 2);
foreach (BoardCell cell in game.LockedCells)
c.Put(4 + cell.Position.X * 2, 3 + cell.Position.Y, cell.Kind.ToString() + " ");
foreach (GridPoint point in game.ActiveCells)
if (point.X >= 0 && point.X < game.Columns && point.Y >= 0 && point.Y < game.Rows)
c.Put(4 + point.X * 2, 3 + point.Y, "[]");
c.Put(30, 4, "NEXT: " + game.NextKind);
foreach (GridPoint offset in catalog[game.NextKind].Offsets(0))
c.Put(32 + offset.X * 2, 6 + offset.Y, "[]");
c.Put(30, 10, $"Placed: {stats.Pieces}");
c.Put(30, 12, "A / D / arrows: move");
c.Put(30, 14, "W / Up: rotate");
c.Put(30, 16, "S / Down: soft drop");
c.Put(30, 18, "SPACE: hard drop");
c.Put(30, 20, "P: pause/resume");
c.Put(30, 21, "ESC: main menu");
c.Put(1, 25, " GAME LOG (latest 5)");
int first = Math.Max(0, messages.Count - 5);
for (int i = first; i < messages.Count; i++) c.Put(1, 26 + i - first, messages[i]);
return c;
}
}
}

TetrisGame/Presentation/ConsoleRenderer.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
using System;
using System.Text;
using TetrisGame.Contracts;

namespace TetrisGame.Presentation
{
public sealed partial class ConsoleRenderer : IRenderer, IViewport
{
private const int Width = 72;
private const int Height = 32;
private bool resizeHintShown;
public bool IsReady => Console.WindowWidth >= Width + 1 && Console.WindowHeight >= Height + 1;

public void ShowResizeHint()
{
if (resizeHintShown)
return;
resizeHintShown = true;
Console.Clear();
Console.WriteLine("Resize terminal to at least 73 columns x 33 rows.");
Console.WriteLine("The game waits automatically. ESC: quit.");
}

private void Present(Canvas canvas)
{
if (!IsReady)
{
ShowResizeHint();
return;
}
resizeHintShown = false;
Console.SetCursorPosition(0, 0);
Console.Write(canvas.ToString());
}

// 仅绘制层需要使用的私有嵌套类,不暴露给规则层。
private sealed class Canvas
{
private readonly char[,] cells = new char[Height, Width];

public Canvas()
{
for (int y = 0; y < Height; y++)
for (int x = 0; x < Width; x++)
cells[y, x] = x == 0 || x == Width - 1 ? '|' : ' ';
Line(0);
Line(Height - 1);
}

public void Put(int x, int y, string text)
{
for (int i = 0; i < text.Length && x + i < Width - 1; i++)
cells[y, x + i] = text[i];
}

public void Line(int y)
{
for (int x = 0; x < Width; x++)
cells[y, x] = x == 0 || x == Width - 1 ? '+' : '-';
}

public void Box(int left, int top, int width, int height)
{
for (int y = top; y < top + height; y++)
for (int x = left; x < left + width; x++)
{
bool horizontal = y == top || y == top + height - 1;
bool vertical = x == left || x == left + width - 1;
if (horizontal || vertical)
cells[y, x] = horizontal && vertical ? '+' : horizontal ? '-' : '|';
}
}

public override string ToString()
{
StringBuilder builder = new StringBuilder((Width + 2) * Height);
for (int y = 0; y < Height; y++)
{
for (int x = 0; x < Width; x++)
builder.Append(cells[y, x]);
if (y < Height - 1)
builder.AppendLine();
}
return builder.ToString();
}
}
}
}

TetrisGame/Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System;
using System.IO;
using TetrisGame.Application;
using TetrisGame.Core;
using TetrisGame.Diagnostics;
using TetrisGame.Domain;
using TetrisGame.Infrastructure;
using TetrisGame.Presentation;

namespace TetrisGame
{
internal static class Program
{
private static int Main()
{
if (Console.IsInputRedirected || Console.IsOutputRedirected)
{ Console.Error.WriteLine("Run this game in an interactive terminal."); return 2; }
try
{
PieceCatalog catalog = new PieceCatalog();
GameTrace.Write($"Loaded {catalog.Count} piece definitions.");
using (ConsoleSession session = new ConsoleSession())
{
ConsoleRenderer renderer = new ConsoleRenderer(catalog);
GameApplication app = new GameApplication(new ConsoleInputSource(), renderer,
new SystemGameClock(), renderer, new RoundFactory(catalog));
app.Run();
}
return 0;
}
catch (IOException ex) { Console.Error.WriteLine("Terminal I/O error: " + ex.Message); return 1; }
catch (ArgumentOutOfRangeException) { Console.Error.WriteLine("Terminal resized during redraw. Resize and restart."); return 1; }
}
}
}

TetrisGame/Scenes/PlayScene.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
using System;
using System.Collections.Generic;
using TetrisGame.Contracts;
using TetrisGame.Core;

namespace TetrisGame.Scenes
{
public sealed class PlayScene : Scene
{
private readonly RoundSession session;
private readonly IGameRenderer renderer;
private readonly List<string> messages = new List<string>();
private TimeSpan accumulated;
public bool Paused { get; private set; }
public PlayScene(ISceneNavigator navigator, IGameRenderer renderer, RoundSession session) : base(navigator)
{ this.renderer = renderer; this.session = session; }
public override void Enter()
{
session.Game.PieceLocked += OnLocked;
AddMessage("Clear rows to score. SPACE: hard drop. P: pause.");
}
private void AddMessage(string message) { if (messages.Count == 5) messages.RemoveAt(0); messages.Add(message); }
private void OnLocked(object? sender, PieceLockedEventArgs e)
=> AddMessage($"Placed {e.Kind}. Lines: {e.ClearedLines}. Clear bonus: {e.AwardedPoints}.");
public override void Exit()
{ session.Game.PieceLocked -= OnLocked; session.Dispose(); }
public override void Handle(InputCommand command)
{
if (command == InputCommand.Escape) { Navigator.ShowMenu(); return; }
if (session.Game.IsGameOver) return;
if (command == InputCommand.Pause)
{ Paused = !Paused; accumulated = TimeSpan.Zero; AddMessage(Paused ? "Paused." : "Resumed."); return; }
if (Paused) return;
switch (command)
{
case InputCommand.Left: session.Game.MoveHorizontal(-1); break;
case InputCommand.Right: session.Game.MoveHorizontal(1); break;
case InputCommand.Rotate: session.Game.Rotate(); break;
case InputCommand.SoftDrop: session.Game.SoftDrop(); break;
case InputCommand.HardDrop: session.Game.HardDrop(); accumulated = TimeSpan.Zero; break;
}
}
public override void Update(TimeSpan elapsed)
{
if (session.Game.IsGameOver) { Navigator.EndRound(new RoundSummary(session)); return; }
if (Paused) return;
accumulated += elapsed;
while (!session.Game.IsGameOver && accumulated >= TimeSpan.FromMilliseconds(session.Game.FallMilliseconds))
{
accumulated -= TimeSpan.FromMilliseconds(session.Game.FallMilliseconds);
session.Game.Tick();
}
if (session.Game.IsGameOver) Navigator.EndRound(new RoundSummary(session));
}
public override void Render() => renderer.DrawGame(session.Game, Paused, messages.AsReadOnly(), session.Statistics.Capture());
}
}

TetrisGame/Scenes/Scene.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
using System;
using TetrisGame.Contracts;
using TetrisGame.Core;

namespace TetrisGame.Scenes
{
public abstract class Scene
{
protected ISceneNavigator Navigator { get; }
protected Scene(ISceneNavigator navigator) => Navigator = navigator;
public virtual void Enter() { }
public virtual void Exit() { }
public abstract void Handle(InputCommand command);
public virtual void Update(TimeSpan elapsed) { }
public abstract void Render();
}

public sealed class StartScene : Scene
{
private readonly IMenuRenderer renderer;
public StartScene(ISceneNavigator navigator, IMenuRenderer renderer) : base(navigator) => this.renderer = renderer;
public override void Handle(InputCommand command)
{ if (command == InputCommand.Start) Navigator.StartRound(); else if (command == InputCommand.Escape) Navigator.Quit(); }
public override void Render() => renderer.DrawStart();
}

public sealed class EndScene : Scene
{
private readonly IMenuRenderer renderer;
private readonly RoundSummary summary;
public EndScene(ISceneNavigator navigator, IMenuRenderer renderer, RoundSummary summary) : base(navigator)
{ this.renderer = renderer; this.summary = summary; }
public override void Handle(InputCommand command)
{
if (command == InputCommand.Replay) Navigator.StartRound();
else if (command == InputCommand.Start) Navigator.ShowMenu();
else if (command == InputCommand.Escape) Navigator.Quit();
}
public override void Render() => renderer.DrawEnd(summary);
}
}

TetrisGame/Services/StatisticsWorker.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
using System;
using System.Collections.Generic;
using System.Threading;
using TetrisGame.Core;
using TetrisGame.Domain;

namespace TetrisGame.Services
{
public sealed class StatisticsWorker : IDisposable
{
private readonly object gate = new object();
private readonly Queue<PieceLockedEventArgs> pending = new Queue<PieceLockedEventArgs>();
private readonly Dictionary<TetrominoKind, int> counts = new Dictionary<TetrominoKind, int>();
private readonly AutoResetEvent signal = new AutoResetEvent(false);
private readonly Thread worker;
private bool stopping;
private bool disposed;
private long pieces, lines, clearPoints;

public StatisticsWorker()
{
worker = new Thread(Run) { IsBackground = true, Name = "Tetris statistics" };
worker.Start();
}

public void OnPieceLocked(object? sender, PieceLockedEventArgs args)
{
lock (gate)
{
if (stopping) return;
pending.Enqueue(args);
// 与入队/停止检查放在同一锁中,避免 Dispose 后再访问 signal。
signal.Set();
}
}

private void Run()
{
while (true)
{
bool handled = false;
lock (gate)
{
if (pending.Count > 0)
{
PieceLockedEventArgs item = pending.Dequeue();
counts.TryGetValue(item.Kind, out int count);
counts[item.Kind] = count + 1;
pieces++; lines += item.ClearedLines; clearPoints += item.AwardedPoints;
handled = true;
}
else if (stopping) return;
}
if (!handled) signal.WaitOne();
}
}

public StatisticsSnapshot Capture()
{
lock (gate)
{
List<PieceCount> snapshot = new List<PieceCount>();
foreach (KeyValuePair<TetrominoKind, int> entry in counts)
snapshot.Add(new PieceCount(entry.Key, entry.Value));
return new StatisticsSnapshot(pieces, lines, clearPoints, snapshot);
}
}

public void Dispose()
{
lock (gate)
{
if (disposed) return;
disposed = true; stopping = true; signal.Set();
}
// 不持有 gate 等待。线程会处理已入队数据,然后正常退出。
worker.Join();
signal.Dispose();
}
}
}

TetrisGame/TetrisGame.csproj

1
2
3
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><LangVersion>12.0</LangVersion><ImplicitUsings>disable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup>
</Project>

C# 学习系列: C# 入门 → C# 基础 → C# 进阶 → C# 高级