C# 入门
C# 学习系列: C# 入门 → C# 基础 → C# 进阶 → C# 高级
从第一个控制台程序开始,学习变量、常量、类型转换、异常、运算符、分支与循环,最后完成控制台小游戏《拯救公主》。
面向零基础读者。配套项目使用 .NET 10,代码采用 C# 12 及以前已有的语法;不依赖第三方包。正文用中文讲解,游戏画面使用单列宽度的英文字符,避免不同终端的中文、表情宽度导致地图错位。
阅读与运行约定
- 按章节顺序学习。前九章中的短代码通常是相互独立的片段,放进第一章的
Main方法中运行;不要把全部片段连续粘贴到一个方法中,否则可能出现同名变量重复声明。 - 标有“完整程序”的示例可直接替换整个
Program.cs;标有“错误示例”的语句保持注释即可,不需要取消注释运行。 - 文中的“声明变量”“显式转换”“项目实践”分别对应常见口头写法“申明变量”“显示转换”“项目事件”,后文采用规范术语。
- 第一遍可以先记住
int、float、double、bool、char、string;object、dynamic、位运算和可空类型理解用途即可。 - 前九章的游戏相关例子是逐项练习;第十章提供整合后的完整代码、运行步骤、玩法和验收清单。
目录
- 第一章:第一个应用程序
- 第二章:变量
- 第三章:常量
- 第四章:转义字符
- 第五章:类型转换
- 第六章:异常捕获
- 第七章:运算符知识
- 第八章:条件分支语句
- 第九章:循环语句
- 第十章:项目实践——拯救公主
- 附录 A:练习参考答案
- 附录 B:常见错误与排查
- 附录 C:官方参考资料
第一章:第一个应用程序
1.1 C#、.NET 与控制台是什么
C# 是编程语言,.NET 提供运行程序所需的运行时和类库,.NET SDK 提供创建、编译和运行项目的开发工具。
可以把控制台程序理解为:在终端窗口接收键盘输入,并输出文字的程序。本教程不使用图形引擎,游戏角色也由字符表示。
| 名称 | 本教程中的作用 |
|---|---|
| C# | 定义程序的语法,例如变量、if、for |
| .NET SDK | 提供 dotnet new、dotnet build、dotnet run |
| .NET 运行时 | 执行编译后的应用程序 |
System.Console |
提供控制台输入、输出和光标定位等功能 |
Program.cs |
放置 C# 源代码的文件 |
.csproj |
描述项目类型、目标框架等设置的项目文件 |
1.2 准备环境并创建项目
从 .NET 官方安装指南 选择适合操作系统的 SDK。本教程的配套项目目标框架为 net10.0,因此直接运行配套项目时需要 .NET 10 SDK;仅安装运行时无法编译代码。
安装后重新打开终端,执行:
1 | |
第一条命令显示默认使用的 SDK 版本;第二条列出已安装的 SDK。
创建一个练习项目:
1 | |
打开 Program.cs,把默认内容全部替换为下面的完整程序:
1 | |
在包含 .csproj 的目录运行:
1 | |
预期输出:
1 | |
如果你使用较早的 SDK 学习前九章,可创建它支持的目标框架项目;不要只修改项目名称而保留一个本机不支持的 TargetFramework。配套游戏的源代码也可放入 .NET 8 项目,但需要安装相应 SDK/运行时,并把项目目标框架改为 net8.0。
1.3 逐行理解程序结构
| 代码 | 含义 |
|---|---|
using System; |
让代码可以直接写 Console,而不必每次写 System.Console |
internal class Program |
定义名为 Program 的类;当前先把它看成组织代码的外壳 |
private static void Main() |
程序入口;运行时从这里开始执行;void 表示不返回结果 |
{ ... } |
用大括号组织代码块 |
Console.WriteLine(...) |
调用一个输出方法;圆括号中放传给方法的数据 |
"你好,C#!" |
字符串字面量,表示一段文字 |
; |
结束一条普通语句;并非每一行都必须写分号,例如代码块的大括号后通常不加 |
现代控制台模板常使用“顶级语句”,所以默认文件可能只有 Console.WriteLine("Hello, World!");。那是省略入口外壳的写法。本教程统一使用显式 Main,方便理解变量作用域,也方便和游戏项目保持一致。入口方法说明
一个练习项目保留一种入口写法即可。 不要在默认顶级语句下面再粘贴另一个 Main 外壳。
1.4 注释:把意图写给阅读代码的人
注释不会作为普通程序语句执行。
1 | |
还有用于类、方法等声明的文档注释 ///。它应放在声明之前,而不是随意写在 Main 内的一条输出语句前:
1 | |
入门阶段先熟练使用 //。尽量解释“为什么这样做”,例如“怪兽死亡后不再反击”,而不是重复语句本身。
1.5 常用控制台语法
| 写法 | 用途 | 是否等待输入 |
|---|---|---|
Console.Write("内容") |
输出后不自动换行 | 否 |
Console.WriteLine("内容") |
输出后自动换行 | 否 |
Console.WriteLine() |
输出一个空行 | 否 |
Console.ReadLine() |
读取一行文字,通常按回车提交 | 是 |
Console.ReadKey(true) |
读取一次按键,不把按键回显到画面 | 是 |
Console.Clear() |
清空控制台显示 | 否 |
Console.SetCursorPosition(x, y) |
把光标移动到指定列和行 | 否 |
先运行一个不需要变量的输入示例:
1 | |
intercept: true 是具名参数,意思是“将 true 传给名为 intercept 的参数”;这里等价于 Console.ReadKey(true)。它用于隐藏按键回显,不是关闭输入等待。ReadKey 说明
ReadLine() 得到的是文字,即使输入 100,它也不会自动变成整数。读取不到更多内容时,它可能返回 null,第五章会处理这种情况。
1.6 第一章练习
依次输出游戏名“拯救公主”、操作说明“WASD 移动,空格互动”,最后等待一次按键。要求名字单独一行,操作说明和提示之间空一行。
第二章:变量
2.1 折叠代码:#region 与 #endregion
折叠代码是编辑器的阅读辅助功能。使用区域指令,为一段代码起名字:
1 | |
支持大纲折叠的编辑器会在区域旁显示折叠控件。折叠只改变编辑器的展示,其中的代码依然会参与编译和执行。区域不创建新的变量作用域,也不替代大括号;每个 #region 都需要配对的 #endregion。区域指令说明
2.2 声明、初始化、赋值与读取
变量用来保存程序运行期间需要使用或修改的数据。声明变量时,需要告诉编译器它的类型和名称。
1 | |
通用形式:
1 | |
= 在这里表示赋值,不是数学中的“相等”。判断是否相等使用第七章的 ==。
1 | |
int 的普通赋值会复制数值。修改 copiedHp 不会顺带修改 originalHp。
2.3 局部变量的作用域与确定赋值
在方法内部声明的变量通常是局部变量。它只能在有效的作用域内使用,常见边界是一对大括号。
1 | |
局部变量必须在读取前被确定地赋值。 不要以为写了 int hp; 就可以直接读取 0:
1 | |
类的字段与数组元素会有默认值,这和局部变量的规则不同。第十章会简要说明字段,当前练习统一给局部变量写清楚初始值。变量声明说明
2.4 有符号与无符号整数
“有符号”表示能存储负数、零和正数;“无符号”只能存储零和正数。同样的位数下,无符号整数把全部取值空间用于非负数。
1 byte = 8 bit。这里的 byte 是存储单位“字节”,C# 中的 byte 则是一个具体类型。
| C# 类型 | 字节数 | 是否有符号 | 最小值 | 最大值 |
|---|---|---|---|---|
sbyte |
1 | 是 | -128 | 127 |
byte |
1 | 否 | 0 | 255 |
short |
2 | 是 | -32,768 | 32,767 |
ushort |
2 | 否 | 0 | 65,535 |
int |
4 | 是 | -2,147,483,648 | 2,147,483,647 |
uint |
4 | 否 | 0 | 4,294,967,295 |
long |
8 | 是 | -9,223,372,036,854,775,808 | 9,223,372,036,854,775,807 |
ulong |
8 | 否 | 0 | 18,446,744,073,709,551,615 |
日常计数、坐标和血量优先使用 int。不能仅因为“血量不应该是负数”就一律选择 uint:减法、边界计算和不同整数类型混合时,int 更容易处理。
1 | |
L、U、UL 是整数字面量后缀;_ 可以用于分隔数字,提高可读性,不改变数值。推荐用大写 L,避免小写 l 与数字 1 混淆。
补充了解:nint、nuint 是本机大小整数,在 32 位进程中为 32 位,在 64 位进程中为 64 位,常用于底层互操作。本教程不需要它们。整数类型说明
2.5 浮点类与小数
| 类型 | 字节数 | 大约有效数字 | 常见用途 | 字面量示例 |
|---|---|---|---|---|
float |
4 | 6~9 位 | 游戏中的位置、速度等 | 3.5f |
double |
8 | 15~17 位 | 一般科学计算、小数运算 | 3.5 或 3.5d |
decimal |
16 | 28~29 位 | 对十进制精度敏感的金额等 | 3.5m |
“有效数字”是整个数值大约能保留的十进制精度,不是小数点后固定保留几位。
1 | |
float 与 double 以二进制浮点形式近似表示数值,有些十进制小数不能精确存储:
1 | |
decimal 能精确表示很多常见的十进制小数,但不能精确表示所有实数,例如 1m / 3m 仍然需要舍入。
表示大数或小数也可以使用科学计数法:
1 | |
这里的 e 是数字中的指数写法,与第四章的转义序列 \e 不是同一个概念。浮点类型说明
2.6 特殊与常见辅助类型
| 写法 | 含义 | 示例 |
|---|---|---|
bool |
布尔值,只有 true 和 false |
bool isAlive = true; |
char |
一个 UTF-16 代码单元,使用单引号 | char heroSymbol = 'H'; |
string |
一段文字,使用双引号 | string heroName = "勇者"; |
object |
通用基类类型,可持有多种类型的值或引用 | object data = 100; |
var |
让编译器从初始化表达式推断局部变量类型 | var hp = 100; |
dynamic |
对它的许多操作推迟到运行时检查 | dynamic data = 100; |
int? |
可空整数,可以保存整数或 null |
int? optionalScore = null; |
string? |
在启用可空分析时标记“可能为 null 的字符串” | string? input = Console.ReadLine(); |
需要分清以下几点。
一、var 不是“任意类型”。
1 | |
二、dynamic 会削弱编译阶段的检查,入门代码不需要使用它。
1 | |
三、char 不等于“屏幕上看到的任意一个字符”。
1 | |
char 的存储大小为 2 字节;字符串则有可变长度,不能把 string 当成“固定占几个字节”的简单数值类型。char 说明
四、空字符串与 null 不同。
1 | |
int? 是可空值类型;string? 是可空引用类型注解,主要用于编译器分析,二者背后的机制不同。当前记住:看到 ? 时,要考虑“没有值”的情况。
2.7 变量的本质:名称、类型与二进制数据
从使用者角度,变量是一个有名字、受类型约束的数据存储位置。类型决定了数据的解释方式、允许的操作和取值范围。
1 | |
二者都与数值 65 有关,但类型不同,输出和使用方式也不同。int 是 System.Int32 的 C# 别名,不是另一套独立类型:
1 | |
按最常见的普通赋值行为,可以先分成两大类:
| 类别 | 变量保存什么 | 典型例子 |
|---|---|---|
| 值类型 | 类型的值本身;普通赋值复制值 | int、float、bool、char |
| 引用类型 | 对对象的引用;普通赋值复制引用 | string、object、普通类 |
不要机械记成“值类型一定在栈上、引用类型一定在堆上”。实际存放位置还取决于字段、数组、装箱等上下文;入门时先理解复制的是值,还是对象的引用。值类型说明、引用类型说明
字符串是引用类型,但字符串对象不可变:
1 | |
这里是让 second 指向另一个字符串,不是修改原字符串对象的内容。
2.8 命名规范
语法规则由编译器检查:名称不能以数字开头,不能包含空格或普通的减号;C# 区分大小写。关键字通常不能直接作名称,可用 @ 转义,但普通业务命名不必刻意这样做。
1 | |
风格约定帮助协作,但并非每一项都是语言强制规定。
| 对象 | 本教程采用的风格 | 示例 |
|---|---|---|
| 局部变量、参数 | camelCase,小写字母开头 | heroHp、nextX、damage |
| 布尔变量 | 使用能表达判断的名称 | isAlive、hasKey、princessFollowing |
| 类、方法 | PascalCase,每个词首字母大写 | Program、StartGame、MoveHero |
| 常量 | PascalCase | MaxHp、MapWidth |
| 私有字段 | 团队可采用 _heroHp 等约定;教学游戏用 camelCase |
heroHp |
优先使用能说明用途的名称,避免把全部变量都命名为 a、b、c。i、x、y 用于简单循环计数和坐标时比较自然。中文名称可以合法使用,但为了阅读类库和协作,示例采用英文名称。命名规则与约定
2.9 第二章练习
声明并输出玩家名字、100 点血量、3.5 的移动速度、是否获得钥匙,以及地图符号 H。分别选择合适的类型;再解释 var hp = 100; 能否在下一行赋值为 "100"。
第三章:常量
3.1 基础语法
常量用来表达编译时就能确定、在程序中不允许再次赋值的数据。
1 | |
基本形式:
1 | |
3.2 常量的特点
- 必须在声明时初始化。
- 初始化表达式必须能在编译阶段求值。
- 声明后不能再次赋值,也不能进行
++、--等修改操作。 - 可以由其他常量组成表达式。
- 可以在方法内定义局部常量,也可以在类中定义常量字段。
const字段不能再加static;它本身就不依赖某个对象实例。
1 | |
常见可用类型包括数值类型、bool、char、string 和枚举类型。不能用 const Random random = new Random(); 来创建随机数对象;除字符串的特殊规则外,引用类型常量只能是 null。const 说明
3.3 常量与变量如何配合
1 | |
MaxHp 是固定规则;heroHp 是随着战斗变化的当前状态。把二者分开,可以减少散落在代码中的不明数字。
这里的 $"...{heroHp}..." 是字符串插值,第五章会详细讲解。
3.4 提前认识 readonly
第十章会看到下面的类字段声明,它应放在 Program 类内部、Main 方法外部:
1 | |
| 对比 | const |
readonly 字段 |
|---|---|---|
| 值何时确定 | 编译时 | 可以在运行时初始化 |
| 可否创建普通对象 | 不能用 new Random() 初始化 |
可以 |
| 赋值位置 | 声明处 | 声明处或对应构造函数中 |
| 引用类型的含义 | 除字符串外通常只能是 null 常量 |
固定字段保存的引用,不保证对象内容不变 |
readonly Random 仍然可以调用 Next();固定的是这个字段不能随意改成另一个 Random 对象,并不是冻结随机数发生器的内部状态。局部变量不能直接照搬这种 readonly 字段声明。
3.5 第三章练习
定义地图总高度为 32,游戏区高度为总高度的四分之三,日志区高度为剩余部分。输出三个结果,说明为什么这些值适合用常量。
第四章:转义字符
4.1 为什么需要转义
普通字符串使用双引号作为边界。如果内容本身也要有双引号,或要包含换行、制表符,就需要特殊写法。
1 | |
普通字符串中的反斜杠 \ 用来引入转义序列。源代码里的两个字符 \n,在字符串中表示一个换行控制字符。
4.2 常用转义字符
| 写法 | 含义 | 示例 |
|---|---|---|
\n |
换行,LF | "第一行\n第二行" |
\r |
回车,CR,通常使光标回到当前行开头 | "Loading\rDone" |
\t |
水平制表符,移动到下一个制表位 | "姓名\t血量" |
\\ |
一个反斜杠 | "C:\\Games" |
\" |
一个双引号 | "她说:\"你好\"" |
\' |
一个单引号,常用于字符字面量 | char quote = '\''; |
1 | |
\t 不是固定数量的空格,中文宽度、字体和终端设置也会影响对齐。游戏地图中需要精确定位时,使用固定宽度的英文字符更容易控制。
\r 与 \n 的含义不同;Windows 文本常使用 \r\n 换行,Unix 风格文本常使用 \n。希望使用当前平台的换行符时,可以写:
1 | |
一般输出一行文字直接使用 WriteLine() 更简单。
4.3 不常用的转义字符
| 写法 | 含义 | 使用时要注意 |
|---|---|---|
\0 |
空字符,U+0000 | 不是 null,也不会像 C 风格字符串那样自动截断 C# 字符串 |
\a |
响铃控制字符 | 终端可能响铃、闪烁,也可能忽略 |
\b |
退格控制字符 | 常表现为光标后退,不保证删除已经显示的内容 |
\f |
换页控制字符 | 在普通终端中通常没有稳定的分页效果 |
\v |
垂直制表符 | 显示行为依赖终端 |
\uXXXX |
恰好 4 位十六进制,表示一个 UTF-16 代码单元 | 例如 \u4E2D 表示“中” |
\UXXXXXXXX |
恰好 8 位十六进制,表示一个 Unicode 码点 | 例如 \U0001F600 可在字符串中表示 😀 |
\xH~\xHHHH |
1~4 位十六进制转义 | 后面紧跟十六进制字符时,容易被一起吃进去 |
\e |
ESC 控制字符,U+001B | C# 13 起支持;C# 12 代码可改用 \u001B |
1 | |
\x 的陷阱:
1 | |
需要可读、稳定的 Unicode 转义时,优先使用固定长度的 \u 或 \U。正文的项目将语言版本设为 C# 12,因此不会使用 \e 语法。字符串与转义说明
4.4 逐字字符串:@"..."
路径中反斜杠很多时,可以使用逐字字符串,反斜杠不再引入普通转义:
1 | |
逐字字符串中,想表示一个双引号要写两个双引号 ""。
@ 还可与插值组合;第五章介绍 $ 后,可以理解 $@"C:\Games\{gameName}" 这种形式。
4.5 原始字符串:了解即可
C# 11 起可以使用至少三个双引号包围原始字符串,适合带有很多引号的文本:
1 | |
多行原始字符串的起始、结束分隔符需要按规则单独占行,结束分隔符的缩进也会影响内容的缩进。入门时先熟悉普通字符串与逐字字符串,再按需使用原始字符串。
4.6 第四章练习
输出两行内容:第一行是 公主说:"勇者,救救我!",第二行是 C:\Games\RescuePrincess。分别使用普通字符串与逐字字符串完成路径输出。
第五章:类型转换
5.1 转换前先判断你要解决什么问题
| 需求 | 常见做法 |
|---|---|
把 int 数值放进 long |
隐式转换 |
把 double 的整数部分取出来 |
显式数值转换,如 (int)value |
把用户输入的 "100" 变成整数 |
int.TryParse、int.Parse |
| 根据具体规则把多种类型转成整数 | Convert.ToInt32 |
| 把血量显示在文字中 | 字符串插值、拼接、ToString() |
数值转换、文本解析和格式化输出是三种不同的任务。 (int) 不能代替 int.Parse 去理解一段数字文本。
5.2 隐式转换:不用写强制转换符
当语言提供相应的隐式转换规则时,可以直接赋值:
1 | |
入门时常见的方向:较小范围的整数转为能容纳它的整数类型、整数转为浮点类型、float 转为 double。但不能把规则简单背成“字节小的一定能隐式转成字节大的”。
1 | |
隐式转换也可能损失精度。 例如某些 int 到 float 的转换允许隐式进行,但目标类型无法精确表示所有整数:
1 | |
隐式转换意味着“语言允许不写转换符”,并不意味着所有情况下都能保留原数值的每一位信息。数值转换规则
5.3 显式转换:写明目标类型
基本形式是 (目标类型)表达式。
1 | |
在范围有效的情况下,浮点数转整数会向零截断,不是四舍五入,也不是总向下取整。
转换超出范围时,不能依赖一个“看起来合理”的结果。整数之间的显式转换可用 checked 检查溢出:
1 | |
unchecked 只是用来说明规则,不建议把它当成处理输入错误的办法。浮点数转换为整数时,若超出范围,checked 会抛出异常;unchecked 下某些结果未被语言规定,不要依赖。decimal 转整数超出范围时也会抛出异常。
布尔类型与数值类型之间没有普通的强制转换:
1 | |
5.4 Parse:把符合格式的字符串解析为值
1 | |
Parse 不是所有类型都按同一套格式理解文字:int.Parse("3.5") 不会帮你取整,bool.Parse("1") 也不会当成 true。
1 | |
上面的 null! 只用于展示异常,! 不会把 null 变成有效字符串;它只是抑制可空分析警告。实际代码不应该用它掩盖输入缺失。
小数点、日期等文本格式可能受当前区域文化影响。示例指定 InvariantCulture 来稳定使用 . 作为小数点;交互软件则应根据面向的用户决定使用哪一种文化规则。
5.5 TryParse:用户输入优先考虑它
用户输错数字很常见,不必把每次输错都当成异常处理。
1 | |
out int hp 的意思是声明一个输出变量,让 TryParse 把解析得到的结果写进去。
| 输入 | 返回值 success |
输出变量 hp |
|---|---|---|
"100" |
true |
100 |
"abc" |
false |
0 |
"999999999999999999" |
false |
0 |
null |
false |
0 |
不能只看 hp == 0 判断失败,因为合法输入 "0" 的结果也是零。应该检查返回的布尔值。第八章会用 if 决定解析成功与失败时分别做什么。TryParse 说明
5.6 Convert:根据重载执行具体转换
1 | |
Convert 不是“万能安全转换”,也可能因为格式、溢出或不支持的类型而抛出异常。判断行为时,需要看调用的是哪个重载。
对 double 转 int,Convert.ToInt32 与强制转换的取整规则不同:
1 | |
因此不要把 Convert.ToInt32(double) 简单记成普通“四舍五入”。Convert.ToInt32 说明
对于明确传入的 string?:
1 | |
Convert 对空引用的某些处理可能把“用户没有输入”变成一个合法数值;需要区分这两种情况时,仍然更适合使用 TryParse。
5.7 字符串拼接、插值与 ToString()
第一种方式是 + 拼接:
1 | |
第二种是字符串插值,通常更清楚:
1 | |
第三种是显式调用 ToString():
1 | |
F2 表示显示为两位小数,改变的是格式化得到的文字,不会改变原始数值。
特别留意运算顺序:
1 | |
字符串拼接不是在把 int 变量本身变成 string。 它只是产生一个新的字符串,原来的变量类型与数值没有变化。第七章会从运算符角度再解释 +。
5.8 第五章练习
读取一行文字,用 TryParse 尝试转换为整数,并输出是否成功。再分别计算 (int)2.9、Convert.ToInt32(2.9)、Convert.ToInt32(2.5),解释三个结果。
第六章:异常捕获
6.1 异常是什么
异常是运行时报告无法按正常流程继续执行的一种机制,例如无法解析数字、除数为零或文件读写失败。
| 情况 | 示例 | 能否通过 try-catch 解决 |
|---|---|---|
| 编译错误 | 少了分号、变量名拼错 | 不能;先让代码通过编译 |
| 运行时异常 | int.Parse("abc") |
可以捕获并按情况处理 |
| 逻辑错误 | 本应加分却写成减分 | 不一定有异常;需要检查规则与结果 |
6.2 try-catch-finally 基础语法
1 | |
其中 input ?? "" 表示:若 input 是 null 就使用空字符串,否则使用 input。?? 是空合并运算符,它与三元条件运算符 ?: 不同。
执行顺序:
- 先执行
try。 - 若没有异常,跳过各个
catch。 - 若某句抛出异常,
try中它后面的语句不再继续执行,寻找第一个匹配的catch。 - 有
finally时,在正常控制流离开这组结构前执行它,包括通过return离开的情况。 - 若异常没有被处理,它会继续向调用方传播。
finally 常用来恢复状态或释放资源;不能把它理解成“无论操作系统强制结束进程还是断电都一定执行”。异常处理说明
6.3 捕获顺序与异常对象
catch 从上往下匹配,具体异常要放在较宽泛的基类异常前面。
1 | |
ex 是异常对象,Message 提供说明,StackTrace 可帮助开发时定位调用路径。面向最终用户通常显示简洁提示;开发时再查看详细异常信息。
不要写空的 catch 把问题无声吞掉,也不要把整个程序都包进一个捕获所有异常、却没有合理处理的代码块。
6.4 常见异常
| 异常 | 常见原因 | 处理思路 |
|---|---|---|
FormatException |
文本格式不符合要求 | 提示正确格式,或改用 TryParse |
OverflowException |
数值超出目标类型范围 | 检查范围、选择合适类型 |
DivideByZeroException |
整数或 decimal 除以零 |
先检查除数 |
ArgumentNullException |
某参数不允许为 null |
检查输入来源和 API 约定 |
ArgumentOutOfRangeException |
参数超出合法范围 | 检查边界,例如控制台光标坐标 |
IOException |
输入输出设备或文件操作失败 | 给出提示,按需要重试或退出 |
NullReferenceException |
对 null 访问成员 |
修复空值来源,不能只靠捕获掩盖问题 |
补充:double、float 的除零不一定抛异常,可能得到 Infinity 或 NaN;它们的规则与整数除法不同。
1 | |
6.5 TryParse 和异常捕获如何选择
| 场景 | 更适合的做法 |
|---|---|
| 用户经常可能输错数字 | TryParse,把失败作为普通分支处理 |
| 读取文件、访问终端等外部操作失败 | 捕获能处理的具体异常 |
| 程序自己的不合法状态 | 查明并修复逻辑,不要直接忽略 |
| 希望显式报告参数非法 | 可以 throw 一个合适的异常,后续课程再深入 |
游戏中会在最外层捕获终端输入输出异常,同时在移动前检查地图边界。正常的“撞墙”属于游戏规则,不需要通过异常实现。
6.6 第六章练习
把字符串解析示例分别输入 100、abc 和超大整数,观察进入哪个分支。解释为什么少写一个分号不能通过 catch 捕获。
第七章:运算符知识
7.1 算术运算符
| 运算符 | 含义 | 示例结果 |
|---|---|---|
+ |
加法 | 7 + 2 为 9 |
- |
减法 | 7 - 2 为 5 |
* |
乘法 | 7 * 2 为 14 |
/ |
除法 | 7 / 2 为 3,7.0 / 2 为 3.5 |
% |
取余 | 7 % 2 为 1 |
++ |
自增 1 | turn++ |
-- |
自减 1 | heroHp-- |
整数除法会先按整数规则得到结果,之后再赋值给浮点变量也无法恢复小数。
1 | |
整数除法向零截断;取余结果的符号与左操作数一致,或结果为零:
1 | |
常见用途:turn % 2 == 0 判断偶数回合,count % pageSize 计算余数。不要把 % 当成“百分比运算符”。算术运算符说明
7.2 赋值与复合赋值
1 | |
对本教程中的简单 int 变量,heroHp -= 10 可以理解成 heroHp = heroHp - 10。对于更复杂的左侧表达式或涉及类型提升的情况,两者不能在所有细节上机械等同。
例如小整数参与加法时通常提升为 int:
1 | |
7.3 前置与后置自增
1 | |
| 写法 | 在表达式中产生什么值 |
|---|---|
a++ |
先使用旧值,再让 a 加一 |
++a |
先让 a 加一,再使用新值 |
单独写成一条语句时,a++; 与 ++a; 都会让 a 增加一。初学时避免在同一个复杂表达式中多次修改同一个变量。
7.4 比较运算符:产生条件结果
有些入门提纲把它们统称为“条件运算符”,但更准确的分类是相等运算符和关系运算符。C# 通常所说的“条件运算符”是后面的 ?:。
| 运算符 | 含义 | 示例 |
|---|---|---|
== |
相等 | heroHp == 100 |
!= |
不相等 | heroHp != 0 |
> |
大于 | heroHp > 0 |
< |
小于 | heroHp < 30 |
>= |
大于或等于 | score >= 60 |
<= |
小于或等于 | heroHp <= 0 |
1 | |
heroHp = 0 是赋值,heroHp == 0 才是判断相等。C# 不允许直接把一个 int 当成 if 条件。
7.5 逻辑运算符与短路
| 运算符 | 含义 | 常见用途 |
|---|---|---|
! |
非:把真变假、假变真 | !isAlive |
&& |
条件与:两边都为真才为真,支持短路 | isAlive && hasKey |
|| |
条件或:至少一边为真就为真,支持短路 | isDead || hasEscaped |
& |
布尔与:两边都会求值 | 了解即可 |
| |
布尔或:两边都会求值 | 了解即可 |
^ |
布尔异或:两边不同才为真 | 两种状态恰好一个成立 |
| A | B | A 与 B | A 或 B | A 异或 B |
|---|---|---|---|---|
true |
true |
true |
true |
false |
true |
false |
false |
true |
true |
false |
true |
false |
true |
true |
false |
false |
false |
false |
false |
短路的意思是:左边的结果已经能确定整体结果时,右边不再执行。
1 | |
如果把第一句的 && 改成 &,右侧仍然会执行,可能抛出整数除零异常。常规条件组合优先使用 &&、||。布尔逻辑运算符说明
判断闭区间时不能直接照抄数学形式:
1 | |
7.6 位运算符:操作整数的二进制位
位运算适合状态标记、掩码等需求;本教程的游戏主体不要求依赖它,但需要认识。
设 a = 6,其低四位为 0110;b = 3,其低四位为 0011:
| 运算符 | 含义 | 示例 | 结果 |
|---|---|---|---|
& |
按位与,两位都为 1 才为 1 | 6 & 3 |
2 |
| |
按位或,至少一位为 1 就为 1 | 6 | 3 |
7 |
^ |
按位异或,两位不同为 1 | 6 ^ 3 |
5 |
~ |
按位取反 | ~6,按 int 的全部 32 位计算 |
-7 |
<< |
左移 | 3 << 2 |
12 |
>> |
右移;有符号整数保留符号方向 | 8 >> 1 |
4 |
>>> |
无符号右移,高位补 0,C# 11 起支持 | -8 >>> 1 |
2147483644 |
~6 不是只把写出来的四位 0110 变成 1001。对 int 来说,它处理完整的 32 位,所以得到 -7。
1 | |
对正整数、且不发生位丢失等问题时,左移一位常对应乘二,右移一位常对应除二。但移位可能丢弃高位,负数右移的规则也不同,不能把它当成所有场景下的普通乘除法替代品。位运算说明
状态标记示例:
1 | |
同一个 & 用于 bool 时是逻辑与,用于整数时是按位与,理解时先看操作数类型。
7.7 三目运算符,也叫三元条件运算符
1 | |
它有三个操作数,作用是根据条件选择一个值。条件为真只计算 ? 后的分支,条件为假只计算 : 后的分支。两个分支需要能形成一个合法的结果类型。条件运算符说明
游戏中可用它限制血量不低于零:
1 | |
也可以使用 Math.Max(0, hp - damage)。完整游戏采用限制实际伤害不超过剩余血量的方式,效果同样是血量最低为零。
7.8 字符串中的 + 与数值中的 +
1 | |
同一个符号在不同类型上有不同含义:数字相加,字符串拼接。代码中混合数字与字符串时,先用括号明确运算顺序,或直接使用字符串插值:
1 | |
重复产生很长字符串时,可在后续课程学习 StringBuilder。本教程为了集中练习基础语法,在固定的小型画面中使用简单拼接。
7.9 常用优先级与溢出
下面只列出本教程常用的一部分优先级,从高到低排列:
| 层级 | 运算符 |
|---|---|
| 1 | 括号内的子表达式先计算 |
| 2 | 一元运算,如 !、~、前置 ++、显式转换 |
| 3 | *、/、% |
| 4 | +、- |
| 5 | <<、>>、>>> |
| 6 | <、>、<=、>= |
| 7 | ==、!= |
| 8 | 按位/布尔 &,然后 ^,然后 | |
| 9 | &&,然后 || |
| 10 | ?: |
| 11 | =、+=、-= 等赋值运算符 |
不必靠复杂表达式展示记忆力;只要阅读上可能有歧义,就加括号。
1 | |
这里先算括号中的 10 + 10,再乘以 2,最终伤害为 40。
整数运算也可能溢出:
1 | |
7.10 第七章练习
把 100 点血量扣除 130 点伤害,并限制最低为零。再定义一个值为 1 << 2 的钥匙标记,完成添加、检查和移除三个操作。
第八章:条件分支语句
8.1 if:满足条件才执行
1 | |
圆括号中的表达式必须得到 bool。大括号中的语句只在条件为真时执行。
1 | |
即使只执行一行,也建议保留大括号,便于后续修改。
8.2 if-else:二选一
1 | |
两个分支只会执行一个。
8.3 if-else if-else:按顺序选择
1 | |
命中一个分支后,不会继续执行后面的 else if。因此有包含关系的条件通常需要从更严格的条件开始判断。
1 | |
多个独立的 if 则可能分别命中,适用于多件互不排斥的事情。
8.4 嵌套与提前结束
嵌套分支是一个分支里再进行一次判断:
1 | |
如果只有一个联合条件,写 if (isAlive && hasKey) 往往更直接。方法中也常用 return 提前结束无效操作,第十章会展示“距离过远就直接返回”的写法。
注意不要在 if (...) 后误写分号:
1 | |
8.5 switch:根据不同取值执行操作
当一个值有多个离散选项时,switch 很适合表达菜单和指令。
1 | |
| 关键字 | 作用 |
|---|---|
switch (command) |
选择用于匹配的值 |
case "1": |
某个匹配选项,末尾是冒号 |
break; |
结束当前 switch 的执行 |
default: |
没有其他匹配项时的处理,可按需求省略 |
C# 不允许一个执行完的非空 case 直接落入下一个 case。分支通常以 break、return 或 throw 等结束控制流的方式离开。
如果多个选项共用一段逻辑,可以把空标签连写:
1 | |
switch 还支持更丰富的模式匹配;当前先掌握普通分支即可。选择语句说明
8.6 三元表达式与 if-else 的关系
下面两种写法表达相同的简单取值逻辑:
1 | |
1 | |
| 要表达的事情 | 选择 |
|---|---|
| 从两个简短值中选一个 | ?: |
| 满足条件后执行多条语句 | if / if-else |
| 同一个值对应多个菜单选项 | switch |
| 多个复杂、不规则的条件范围 | if-else if |
?: 是表达式,不是与 if 并列的独立分支语句。不要为了缩短行数而堆叠很多层嵌套三元表达式。
8.7 输入判断综合示例
现在可以把第五章的 TryParse 与分支结合起来:
1 | |
成功解析为整数和满足业务范围是两个检查:-10 能解析成 int,但不符合这个游戏的初始血量规则。
8.8 第八章练习
读取菜单选项:1 显示“开始游戏”,2 显示“查看帮助”,其他输入显示“无效选项”。再用 if 判断血量:小于等于零显示死亡,1~30 显示危险,31 以上显示正常。
第九章:循环语句
9.1 while:先判断,再执行
1 | |
执行顺序:判断 count <= 5,为真就执行循环体,再回到条件处。条件第一次就是假时,循环体一次也不会执行。
flowchart TD
A[初始化变量] --> B{"条件为真?"}
B -->|是| C[执行循环体]
C --> D[更新状态]
D --> B
B -->|否| E[执行循环后语句]
示例中的 count++ 会逐步让条件变为假。如果忘记更新变量,可能产生无限循环:
1 | |
9.2 do...while:至少执行一次
1 | |
虽然初始值 10 已经不满足条件,循环体仍然先执行一次,输出 10。注意 while (...) 后有一个分号。
它适合“先做一次,再判断是否重来”的场景,例如先生成一个随机位置,再检查是否与怪兽重叠。
9.3 for:把计数过程写在一起
1 | |
输出编号 0、1、2、3、4,共五次。
基本结构:
1 | |
执行顺序是:初始化一次 → 判断条件 → 执行循环体 → 更新 → 再判断条件。
1 | |
for (int i = 0; i < 5; i++) 中的 i 只在这条 for 语句的作用域内有效。
9.4 三种循环如何选择
| 循环 | 最少执行次数 | 更适合的场景 |
|---|---|---|
while |
0 | 不确定次数,按状态持续运行,例如游戏主循环 |
do...while |
1 | 必须先尝试一次,例如随机生成合法坐标 |
for |
0 | 次数或范围清晰,例如逐行逐列画地图 |
三者经常能互相改写,选最容易表达意图的即可。循环语句说明
9.5 break:提前结束最近一层循环
1 | |
这里同时处理 null,避免标准输入结束后永远读取不到新内容,却还一直重复循环。
while (true) 可以是合法设计,只要明确提供退出条件。第十章游戏每轮使用 ReadKey 等待按键,不会仅为了等待而高速空转。
9.6 continue:跳过本轮剩余部分
1 | |
输出 2、4、6、8、10。continue 不是结束整个循环,而是开始下一轮:for 会先执行更新部分,再判断条件;while 则直接回到条件判断处。
因此在 while 中,更新变量要放在不会被意外跳过的位置:
1 | |
9.7 嵌套循环:按行、按列绘制
1 | |
外层控制行,内层控制这一行的列。边界条件通过 || 组合:位于最左、最右、最上或最下的任意一条边时,输出边框字符。
嵌套循环里的 break 只退出最近的一层循环;如果它写在内层 switch 中,则只退出该 switch。不要把它和“退出整个程序”混为一谈。
9.8 简化战斗练习
1 | |
这一版使用固定伤害、自动连续执行,用于观察循环与退出顺序。下一章会改成“每按一次空格进行一个回合”,并加入随机伤害。
9.9 第九章练习
用 for 输出 1~20 中所有偶数;用 while 不断读取输入,直到用户输入 q 或输入流结束;解释为什么随机出生点的重试适合使用 do...while。
第十章:项目实践——拯救公主
10.1 目标与规则
本项目把前九章的知识组合起来,完成一个基于按键回合的控制台小游戏。
| 功能 | 明确规则 |
|---|---|
| 开始场景 | 显示标题、玩法;回车开始,Esc 退出 |
| 玩家出生 | 每局在地图内部随机生成,不与怪兽重叠 |
| 怪兽 | 固定在坐标 (54, 12),不会主动移动 |
| 移动 | WASD 或方向键,每次移动一格;不能穿越边框或存活的怪兽 |
| 战斗范围 | 只有怪兽上、下、左、右四格;斜角不算相邻 |
| 战斗触发 | 在有效范围按空格,一次空格执行一个回合 |
| 初始血量 | 玩家与怪兽均为 100 |
| 随机伤害 | 玩家和怪兽分别独立抽取 10~25 的攻击值 |
| 回合顺序 | 玩家先攻击;怪兽若存活才反击;血量最低为零 |
| 战斗失败 | 玩家血量变为零,进入失败场景 |
| 公主出现 | 怪兽死亡后,在怪兽原位置出现;公主不另设战斗血量 |
| 出口出现 | 击败怪兽后,在地图内部随机生成 E,避开玩家与公主 |
| 开始护送 | 在公主四向相邻格再次按空格,公主开始跟随 |
| 跟随方式 | 玩家每次成功移动后,公主移动到玩家上一格;撞墙时都不动 |
| 胜利条件 | 护送状态下玩家走到 E,视为两人一同通过出口,进入胜利场景 |
| 提前到出口 | 未接到公主时不会通关,显示先救公主的提示 |
| 重新开始 | 结束场景按 R;回车回主菜单;Esc 退出 |
| 游戏中返回 | Esc 回主菜单,本局不保存 |
这里把“随机出现的出口”实现为地图内部的传送出口,因此四周边框始终完整。公主跟在玩家后一格,玩家踏入出口时触发两人共同离开的结局,不要求两个人同时占据同一格。
10.2 场景切换与主要流程
flowchart TD
A[开始场景] -->|回车| B[探索与战斗]
B -->|玩家死亡| C[失败场景]
B -->|击败怪兽| D[出现公主与出口]
D -->|相邻按空格| E[护送公主]
E -->|走到出口| F[胜利场景]
C -->|R| B
F -->|R| B
C -->|回车| A
F -->|回车| A
B -->|Esc| A
D -->|Esc| A
E -->|Esc| A
代码中的 scene 只需要三个值:0 表示开始,1 表示游戏,2 表示结束。游戏内部的阶段由 monsterHp 与 princessFollowing 决定,结束场景再通过 won 区分胜负。
10.3 画面与坐标设计
控制台左上角为 (0, 0):x 越大越向右,y 越大越向下。上移是 y - 1,下移是 y + 1。
程序画面固定为 72 列 × 32 行。游戏区包含状态栏和边框,共占 24 行;日志区共占 8 行,比例为 3:1。
| 行号,均从 0 开始 | 行数 | 内容 |
|---|---|---|
| 0 | 1 | 游戏区上边框 |
| 1 | 1 | 玩家/怪兽血量、回合、当前目标 |
| 2 | 1 | 状态栏分隔线 |
| 3~22 | 20 | 可行走地图;左右有边框 |
| 23 | 1 | 游戏区下边框 |
| 24 | 1 | 日志区上边框 |
| 25 | 1 | 日志标题与快捷键 |
| 26~30 | 5 | 最近五条战斗或交互记录 |
| 31 | 1 | 日志区下边框 |
可行走范围为 x = 1~70、y = 3~22。不要把画面总高度 32 直接当成地图可行走范围,否则角色会走进状态栏或日志区。
| 画面字符 | 中文含义 |
|---|---|
H |
Hero,玩家 |
M |
Monster,怪兽 |
P |
Princess,公主 |
E |
Exit,出口 |
+、-、竖线 |
边框与分隔线 |
| 空格 | 可以行走的地面 |
终端窗口需要至少 73 列 × 33 行,比实际画面多留一列和一行,避免最后一列自动换行、最后一行滚动。代码会检查窗口大小,过小时提示调整。
10.4 必要的补充:方法与字段
为了让项目容易阅读,需要先认识少量组织代码的语法,不必在此展开完整的面向对象课程。
方法把一段操作起一个名字,之后通过名字调用:
1 | |
| 语法 | 当前需要理解的内容 |
|---|---|
private |
这个方法或字段在当前类内部使用 |
static |
本项目直接通过同一组静态方法、字段组织程序,不必先创建 Program 对象 |
void |
方法不返回结果 |
int、bool、string 作为返回类型 |
方法调用后会产生相应类型的结果 |
(int currentHp, int damage) |
调用方传入的数据,称为参数 |
return 值; |
结束方法并返回结果 |
return; |
提前结束一个 void 方法 |
字段声明在类内部、方法外部。游戏把当前血量、坐标等保存为字段,多个方法就能共同访问它们。
1 | |
字段具有默认值:int 默认为 0,bool 默认为 false,引用类型默认为 null。不过每局开始时仍应明确重置状态,避免上一局的数据影响下一局。
本项目不引入数组、集合、自定义事件、继承等额外主题。五条日志暂时使用五个字符串字段保存,等学习数组或队列后可以再重构。
10.5 随机数、按键和字符串辅助 API
随机数:
1 | |
Next(min, max) 的范围是左闭右开 [min, max)。需要覆盖 1~70 时应该写 Next(1, 71)。创建一个发生器并重复使用即可,不需要每次攻击都重新创建对象。本项目中的随机数用于游戏规则,不用于安全用途。Random.Next 说明
按键:
1 | |
ConsoleKey 是控制台提供的枚举类型,可以先理解成一组有名称的按键值,例如 W、Enter、Escape、Spacebar。使用 .Key 识别 W 键时,不需要另外处理字符 'w'、'W'。
完整代码还使用以下辅助功能:
| API | 用途 |
|---|---|
Math.Abs(value) |
取绝对值,用于计算四向距离 |
Math.Min(a, b) |
取较小值,防止实际伤害超过剩余血量 |
new string('-', count) |
生成重复字符组成的字符串,用来画横边框 |
text.Length |
字符串中的 UTF-16 代码单元数量 |
text.Substring(0, length) |
截取指定长度的文字 |
text.PadRight(length) |
用空格补齐到指定长度 |
$"{heroHp,3}" |
把数值按至少 3 个字符宽度右对齐 |
Environment.NewLine |
当前平台的换行符 |
Console.CursorVisible |
设置光标是否显示 |
Console.WindowWidth/WindowHeight |
查询终端当前大小 |
Console.IsInputRedirected/IsOutputRedirected |
判断是否通过重定向运行 |
10.6 先搭出关键算法
一、移动前先计算目标位置,不要先改坐标再想办法撤回。
1 | |
确认 inside,再检查怪兽、公主等占位规则,最后提交坐标变化。无效操作会提前 return。
二、四向相邻用曼哈顿距离。
1 | |
横向差一格、纵向相同时,距离为 1;斜角两项都差一格,距离为 2,所以不能攻击。
三、玩家先攻击,判定击杀后才允许怪兽反击。
flowchart TD
A[相邻按空格] --> B[玩家攻击并扣除怪兽血量]
B --> C{"怪兽死亡?"}
C -->|是| D[生成公主与随机出口]
C -->|否| E[怪兽反击并扣除玩家血量]
E --> F{"玩家死亡?"}
F -->|是| G[进入失败场景]
F -->|否| H[等待下一次按键]
D --> H
四、公主跟随玩家上一格。
先保存 oldX、oldY,再更新玩家坐标,最后让公主移动到旧坐标。只有玩家成功移动后才执行这个步骤。玩家反向走到公主当前格时,两者自然交换位置,不会卡住。
五、随机出口不能覆盖已有角色。
先生成一个候选坐标,若与玩家或公主重叠,就重新生成。本地图没有内部障碍物,剩余空地都可到达,因此不会生成在无法进入的封闭区域。
10.7 创建与运行项目
使用配套源码包: 解压后进入 RescuePrincess 目录,在终端运行:
1 | |
自己从零创建:
1 | |
把下一节完整代码替换进 Program.cs,再运行 dotnet run。不要把它粘贴到另一个 Main 的内部。
配套项目的 RescuePrincess.csproj 为:
1 | |
请在 Windows Terminal、PowerShell、命令提示符,或编辑器的集成终端运行。某些 IDE 的“输出”面板、重定向到文件的运行方式和没有交互终端的在线环境不支持 ReadKey 与光标定位。
10.8 完整代码:Program.cs
1 | |
10.9 按职责阅读完整代码
| 方法 | 负责什么 | 对应的主要基础知识 |
|---|---|---|
Main |
根据场景循环绘制、读键、分派操作,退出时恢复光标 | while、switch、异常捕获 |
WaitForTerminalSize |
窗口太小时等待调整 | while、条件判断、控制台输入 |
StartGame |
重置状态并随机生成玩家位置 | 常量、变量、do...while |
HandleGameKey |
把按键转成 dx、dy 或互动命令 |
switch、多个 case、return |
MoveHero |
检查碰撞、移动玩家和公主、判断通关 | 比较、逻辑运算、if |
IsAdjacent |
判断是否四向相邻 | 算术运算、相等判断、布尔返回值 |
Interact |
在当前阶段执行攻击或接到公主 | if-else、逻辑运算 |
FightOneTurn |
一次随机攻防并处理死亡 | 随机数、变量修改、分支、插值 |
SpawnExit |
反复随机直到出口不覆盖角色 | do...while、&&、|| |
AddLog |
将五条日志向前移动并加入新消息 | 字符串变量和赋值顺序 |
Border、BoxLine |
生成长度一致的边框和文字行 | 字符串拼接、长度检查 |
MenuLine、GameLine |
根据行号决定这一行画什么 | if、switch、for、三元表达式 |
Draw |
组装并覆盖绘制完整画面 | for、拼接、光标定位 |
不要一开始就试图背完全部代码。可以按以下顺序动手:
- 只运行菜单,确认回车能进入游戏、Esc 能返回。
- 阅读
StartGame与MoveHero,确认坐标和边界。 - 阅读
IsAdjacent、Interact与FightOneTurn,理解一个回合。 - 阅读
SpawnExit和公主跟随部分,理解通关条件。 - 最后看字符串绘制,尝试修改状态栏文字或地图符号。
10.10 为什么这样处理几个细节
保持画面固定。 每一行统一为 72 个英文字符宽度,每次先把光标移回 (0, 0),再输出完整画面,不在每次按键后都 Clear()。这样可减少清屏带来的闪烁,也不会把不断增长的战斗文字写到地图下面。
最后一行不额外换行。 终端最后一行的换行可能导致整体滚动,因此 Draw 只在各行之间插入换行符。
显示的是实际扣血。 如果怪兽只剩 3 点血量,随机攻击值是 20,实际伤害显示为 3,血量变为零,不显示负数。
死亡判定立即结束相关流程。 怪兽死亡后 return,本回合不再反击;玩家死亡后切换结束场景,下一轮主循环便不再处理游戏移动。
护送必须显式触发。 击败怪兽只会生成公主,不会自动把她变成跟随状态;需要相邻再次按空格。这样救援与护送两个阶段能够清晰区分。
每局重新设置全部状态。 R 重新开始时,双方恢复 100 点血量,回合清零,护送与胜负标志复位,旧出口和旧日志清除,玩家重新随机出生。
恢复终端状态。 程序通过 finally 恢复光标显示并重置颜色。终端在绘制瞬间缩小时仍可能报告坐标异常,程序会提示调整后重新运行。
10.11 实际游玩步骤
- 调整终端到至少 73 列 × 33 行,运行程序。
- 在开始场景按回车,找到代表玩家的
H与固定位置的M。 - 用 WASD 移动到
M的上、下、左或右侧。 - 按一次空格,查看下方玩家攻击与怪兽反击的记录。继续按空格,直到一方血量归零。
- 如果失败,在结束场景按 R 开始新一局。
- 如果获胜,
M的位置显示为P,地图其他空地显示E。 - 保持与
P相邻,按空格,状态栏目标变为ESCORT P TO E。 - 向
E移动,观察P始终跟在上一格。走到出口后进入胜利场景。
两边伤害均随机,玩家有先手优势但不保证每局必胜。这使失败场景也具有实际意义。
10.12 验收清单
| 检查项 | 预期行为 |
|---|---|
| 连续开始多局 | 玩家位置有变化,怪兽固定;双方每局初始均为 100 HP |
| 从四个方向尝试越界 | 不能进入边框、状态栏或日志区 |
| 走向存活怪兽所在格 | 被阻挡;提示在相邻位置按空格 |
| 远处或斜角按空格 | 不扣血、不增加回合 |
| 相邻按一次空格 | 玩家攻击一次;怪兽存活才反击一次 |
| 怪兽被斩杀 | 不反击;公主原地出现,随机出口不覆盖角色 |
| 玩家被打到零血 | 进入失败场景,血量不会变成负数 |
| 没接公主先走到出口 | 提示先救公主,不触发胜利 |
| 公主相邻按空格 | 开启跟随;重复空格不会重置公主位置 |
| 护送时走动、回头或撞墙 | 跟随玩家上一格;回头可交换位置;撞墙时都不动 |
| 护送走到出口 | 进入胜利场景 |
| 结束后按 R | 新局恢复全部初始状态 |
| 上下画面区域 | 游戏区 24 行,日志区 8 行;日志最多显示最近五条 |
| 窗口过小 | 提示扩大终端,按键后重新检查 |
验证记录:配套源码已在 .NET 10 环境编译通过,编译警告与错误均为零;程序化检查覆盖了坐标范围、出生与出口重叠、四向攻击、伤害范围、击杀不反击、死亡、招募、跟随、出口判定、重开和固定画面尺寸。另外,已通过交互终端按键跑通战斗、招募、护送、胜利、重开及退出流程,并检查窗口过小时的恢复提示。真实终端的视觉效果还会受字体与窗口设置影响。
10.13 后续练习
掌握当前版本后,再逐步增加功能:
- 把伤害范围变成开始菜单中的难度选项。
- 增加一次性恢复药水,练习变量、范围限制和输入处理。
- 使用数组或队列管理日志,替代五个字符串字段。
- 为地图增加内部障碍物,同时保证出生点和出口可以到达。
- 使用类或结构体管理角色坐标和血量,减少零散字段。
- 迁移到 Unity 时,把输入、坐标、渲染分别换成引擎提供的功能;保留战斗与通关规则,并重新检查帧更新下的输入时机。
附录 A:练习参考答案
下面的短代码放入第一章的 Main 方法内运行,每题单独运行。
A.1 第一个应用程序
1 | |
WriteLine() 用于单独空一行,ReadKey(true) 用于等待并隐藏按键回显。
A.2 变量
1 | |
var hp = 100; 推断为 int,之后不能赋值为字符串 "100"。
A.3 常量
1 | |
结果为 32、24、8。它们是当前版本的固定布局规则,能在编译时求值,所以适合使用常量。若以后允许玩家在设置中动态修改,就应考虑变量或运行时配置。
A.4 转义字符
1 | |
最后两句输出相同路径,第三句的反斜杠不需要写两次。
A.5 类型转换
1 | |
强制转换向零截断;Convert.ToInt32(double) 取最近的整数,恰好在中点时选择偶数。
A.6 异常捕获
第六章的 try-catch 示例运行结果为:
| 输入 | 行为 |
|---|---|
100 |
输出解析成功,然后执行 finally |
abc |
进入 FormatException 分支,然后执行 finally |
| 很大的整数文本 | 进入 OverflowException 分支,然后执行 finally |
缺少分号是编译错误,程序尚未运行,所以不可能进入运行时的 catch。
A.7 运算符
1 | |
用 & 取出对应标记后,应检查是否非零,而不是固定检查是否等于 1。钥匙标记本身是 4。
A.8 分支
1 | |
第二个条件不用重复写 hp >= 1,因为 hp <= 0 已经在前一个分支处理。
A.9 循环
1 | |
出生点重试需要至少生成一次候选位置,再判断是否重叠,所以 do...while 很自然。重试条件应写成“这次生成的位置不合法”,不是“位置合法”。
附录 B:常见错误与排查
| 现象 | 优先检查 |
|---|---|
提示找不到 dotnet |
是否安装 SDK;安装后是否重新打开终端;系统路径是否生效 |
| 提示 SDK 不支持目标框架 | 配套项目是 net10.0;安装相应 SDK,或在理解兼容性的前提下调整目标框架 |
| 提示找不到可运行项目 | 当前目录是否包含 .csproj;或者用 dotnet run --project 路径 |
| 同名变量重复声明 | 是否把多个独立示例粘贴到了同一作用域 |
| 多入口或顶级语句相关提示 | 是否保留了模板的顶级语句,又添加了完整 Program 类 |
| 缺少分号或括号 | 检查报错行前后;标点是否误用了中文全角形式 |
| 输入数字后仍不能计算 | ReadLine() 返回文字,需要解析为数值 |
小数赋给 float 报错 |
是否写了 f 后缀,或是否需要显式转换 |
| 数值看起来像少了小数 | 是否先发生了整数除法,例如 7 / 2 |
| 某些相邻位置不能攻击 | 本游戏只允许四向相邻,斜角不在攻击范围内 |
| 游戏按键没有作用 | 终端是否获得焦点;输入法是否在组合输入;是否误用输出面板 |
| 地图错位或滚动 | 窗口是否至少 73×33;字体是否等宽;是否向末行额外输出了换行 |
| 看不到怪兽 | 检查游戏是否已进入护送阶段,此时怪兽位置应显示为公主 |
| 走到出口没通关 | 是否先在公主相邻位置按空格,开启了跟随 |
| 游戏文字是英文 | 这是固定列宽的绘制设计,中文符号含义见第十章图例 |
| 游戏出现大小提示 | 调整终端大小后按一个键重新检查,Esc 可直接退出 |
建议按“编译是否通过 → 终端是否正常 → 当前场景 → 角色坐标 → 血量与跟随状态”的顺序排查,而不是同时修改多处代码。
附录 C:官方参考资料
本教程的说明与示例为教学编排;下面的 Microsoft Learn 页面用于核对语言规则和 API 行为。语言参考可能包含较新版本的语法,阅读时注意版本要求。
| 编号 | 官方资料 | 对应内容 |
|---|---|---|
| S01 | .NET 安装指南 | SDK、系统环境 |
| S02 | Main 与命令行参数 | 程序入口 |
| S03 | Console.ReadKey | 按键输入与回显 |
| S04 | C# 预处理器指令 | #region 与代码折叠 |
| S05 | 声明语句 | 变量、var、局部常量 |
| S06 | 整数类型 | 范围、有符号与无符号 |
| S07 | 浮点数值类型 | float、double、decimal |
| S08 | char 类型 | UTF-16 代码单元 |
| S09 | 值类型 | 值复制语义 |
| S10 | 内置引用类型 | object、string、dynamic |
| S11 | 标识符与命名约定 | 合法命名与代码风格 |
| S12 | const 关键字 | 常量与 readonly 对比 |
| S13 | 字符串与字符串字面量 | 转义、逐字字符串、原始字符串 |
| S14 | 内置数值转换 | 隐式与显式转换 |
| S15 | Int32.TryParse | 不抛格式异常的整数解析 |
| S16 | Convert.ToInt32 | 不同转换重载和取整规则 |
| S17 | 异常处理 | try、catch、finally |
| S18 | 算术运算符 | 加减乘除、自增、取余 |
| S19 | 布尔逻辑运算符 | 逻辑、短路 |
| S20 | 位与移位运算符 | 掩码、移位 |
| S21 | 三元条件运算符 | ?: |
| S22 | 选择语句 | if、switch |
| S23 | 循环语句 | while、do、for |
| S24 | Random.Next | 随机数范围 |