C# 入门

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

从第一个控制台程序开始,学习变量、常量、类型转换、异常、运算符、分支与循环,最后完成控制台小游戏《拯救公主》。

面向零基础读者。配套项目使用 .NET 10,代码采用 C# 12 及以前已有的语法;不依赖第三方包。正文用中文讲解,游戏画面使用单列宽度的英文字符,避免不同终端的中文、表情宽度导致地图错位。

阅读与运行约定

  1. 按章节顺序学习。前九章中的短代码通常是相互独立的片段,放进第一章的 Main 方法中运行;不要把全部片段连续粘贴到一个方法中,否则可能出现同名变量重复声明。
  2. 标有“完整程序”的示例可直接替换整个 Program.cs;标有“错误示例”的语句保持注释即可,不需要取消注释运行。
  3. 文中的“声明变量”“显式转换”“项目实践”分别对应常见口头写法“申明变量”“显示转换”“项目事件”,后文采用规范术语。
  4. 第一遍可以先记住 int、float、double、bool、char、string;object、dynamic、位运算和可空类型理解用途即可。
  5. 前九章的游戏相关例子是逐项练习;第十章提供整合后的完整代码、运行步骤、玩法和验收清单。

目录

第一章:第一个应用程序

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
2
dotnet --version
dotnet --list-sdks

第一条命令显示默认使用的 SDK 版本;第二条列出已安装的 SDK。

创建一个练习项目:

1
2
dotnet new console -n CSharpBasics --framework net10.0
cd CSharpBasics

打开 Program.cs,把默认内容全部替换为下面的完整程序:

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

internal class Program
{
private static void Main()
{
Console.WriteLine("你好,C#!");
Console.WriteLine("我的第一个应用程序运行成功。");
}
}

在包含 .csproj 的目录运行:

1
dotnet run

预期输出:

1
2
你好,C#!
我的第一个应用程序运行成功。

如果你使用较早的 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
2
3
4
5
6
7
8
9
// 单行注释:解释这一行或下一段代码的意图。
Console.WriteLine("准备出发!"); // 也可以写在语句后面。

/*
多行注释:
适合较长的说明。
注意:这种注释不能直接互相嵌套。
*/
Console.WriteLine("前往城堡。");

还有用于类、方法等声明的文档注释 ///。它应放在声明之前,而不是随意写在 Main 内的一条输出语句前:

1
2
3
4
5
6
7
8
// 声明示例:放在 Program 类内部、Main 方法外部。
/// <summary>
/// 显示游戏标题。
/// </summary>
private static void ShowTitle()
{
Console.WriteLine("拯救公主");
}

入门阶段先熟练使用 //。尽量解释“为什么这样做”,例如“怪兽死亡后不再反击”,而不是重复语句本身。

1.5 常用控制台语法

写法 用途 是否等待输入
Console.Write("内容") 输出后不自动换行 否
Console.WriteLine("内容") 输出后自动换行 否
Console.WriteLine() 输出一个空行 否
Console.ReadLine() 读取一行文字,通常按回车提交 是
Console.ReadKey(true) 读取一次按键,不把按键回显到画面 是
Console.Clear() 清空控制台显示 否
Console.SetCursorPosition(x, y) 把光标移动到指定列和行 否

先运行一个不需要变量的输入示例:

1
2
3
4
Console.Write("请输入你的名字:");
Console.WriteLine("欢迎你," + Console.ReadLine());
Console.WriteLine("按任意键继续……");
Console.ReadKey(intercept: true);

intercept: true 是具名参数,意思是“将 true 传给名为 intercept 的参数”;这里等价于 Console.ReadKey(true)。它用于隐藏按键回显,不是关闭输入等待。ReadKey 说明

ReadLine() 得到的是文字,即使输入 100,它也不会自动变成整数。读取不到更多内容时,它可能返回 null,第五章会处理这种情况。

1.6 第一章练习

依次输出游戏名“拯救公主”、操作说明“WASD 移动,空格互动”,最后等待一次按键。要求名字单独一行,操作说明和提示之间空一行。

第二章:变量

2.1 折叠代码:#region 与 #endregion

折叠代码是编辑器的阅读辅助功能。使用区域指令,为一段代码起名字:

1
2
3
4
5
6
7
8
9
#region 玩家信息
int heroHp = 100;
int heroAttack = 20;
#endregion

#region 输出信息
Console.WriteLine(heroHp);
Console.WriteLine(heroAttack);
#endregion

支持大纲折叠的编辑器会在区域旁显示折叠控件。折叠只改变编辑器的展示,其中的代码依然会参与编译和执行。区域不创建新的变量作用域,也不替代大括号;每个 #region 都需要配对的 #endregion。区域指令说明

2.2 声明、初始化、赋值与读取

变量用来保存程序运行期间需要使用或修改的数据。声明变量时,需要告诉编译器它的类型和名称。

1
2
3
4
5
6
int heroHp;       // 声明:创建一个局部变量。
heroHp = 100; // 赋值:给变量保存一个值。
Console.WriteLine(heroHp); // 读取:使用变量当前的值。

int monsterHp = 100; // 声明时直接赋初始值,称为初始化。
heroHp = 80; // 再次赋值:覆盖之前的值。

通用形式:

1
2
// 类型 变量名 = 初始值;
int score = 0;

= 在这里表示赋值,不是数学中的“相等”。判断是否相等使用第七章的 ==。

1
2
3
4
5
int originalHp = 100;
int copiedHp = originalHp;
copiedHp = 30;
Console.WriteLine(originalHp); // 100
Console.WriteLine(copiedHp); // 30

int 的普通赋值会复制数值。修改 copiedHp 不会顺带修改 originalHp。

2.3 局部变量的作用域与确定赋值

在方法内部声明的变量通常是局部变量。它只能在有效的作用域内使用,常见边界是一对大括号。

1
2
3
4
5
6
7
8
int heroHp = 100;
{
int damage = 20;
heroHp = heroHp - damage;
}

Console.WriteLine(heroHp); // 80
// Console.WriteLine(damage); // 错误:damage 已超出作用域。

局部变量必须在读取前被确定地赋值。 不要以为写了 int hp; 就可以直接读取 0:

1
2
3
4
int hp;
// Console.WriteLine(hp); // 错误:使用了未赋值的局部变量。
hp = 100;
Console.WriteLine(hp);

类的字段与数组元素会有默认值,这和局部变量的规则不同。第十章会简要说明字段,当前练习统一给局部变量写清楚初始值。变量声明说明

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
2
3
4
5
int heroHp = 100;
byte colorChannel = 255;
long totalScore = 9_000_000_000L;
uint count = 100U;
ulong veryLargeCount = 18_000_000_000UL;

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
2
3
4
5
float moveSpeed = 3.5f;
double distance = 123.456;
decimal price = 19.90m;

// float speed = 3.5; // 错误:3.5 默认是 double。

float 与 double 以二进制浮点形式近似表示数值,有些十进制小数不能精确存储:

1
2
Console.WriteLine(0.1 + 0.2 == 0.3);    // False
Console.WriteLine(0.1m + 0.2m == 0.3m); // True

decimal 能精确表示很多常见的十进制小数,但不能精确表示所有实数,例如 1m / 3m 仍然需要舍入。

表示大数或小数也可以使用科学计数法:

1
2
double large = 1.2e6;  // 1,200,000
double small = 2.5e-3; // 0.0025

这里的 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
2
3
4
var hp = 100; // 编译时已经确定为 int。
hp = 80;
// hp = "很多血"; // 错误:不能把 string 赋给 int。
// var unknown; // 错误:没有初始值就无法推断类型。

二、dynamic 会削弱编译阶段的检查,入门代码不需要使用它。

1
2
3
dynamic data = 100;
data = "文字"; // 允许,但操作是否有效要在运行时判断。
Console.WriteLine(data);

三、char 不等于“屏幕上看到的任意一个字符”。

1
2
3
4
5
char letter = 'A';
char chinese = '中';
string emoji = "😀";
Console.WriteLine(emoji.Length); // 2:这个表情使用两个 UTF-16 代码单元。
// char invalid = '😀'; // 错误:无法放进单个 char。

char 的存储大小为 2 字节;字符串则有可变长度,不能把 string 当成“固定占几个字节”的简单数值类型。char 说明

四、空字符串与 null 不同。

1
2
3
string empty = "";        // 存在一个长度为 0 的字符串。
string? missing = null; // 当前没有字符串对象的引用。
Console.WriteLine(empty.Length); // 0

int? 是可空值类型;string? 是可空引用类型注解,主要用于编译器分析,二者背后的机制不同。当前记住:看到 ? 时,要考虑“没有值”的情况。

2.7 变量的本质:名称、类型与二进制数据

从使用者角度,变量是一个有名字、受类型约束的数据存储位置。类型决定了数据的解释方式、允许的操作和取值范围。

1
2
3
4
int number = 65;
char letter = 'A';
Console.WriteLine(number); // 65
Console.WriteLine(letter); // A

二者都与数值 65 有关,但类型不同,输出和使用方式也不同。int 是 System.Int32 的 C# 别名,不是另一套独立类型:

1
2
3
int a = 100;
System.Int32 b = 100;
Console.WriteLine(a == b); // True

按最常见的普通赋值行为,可以先分成两大类:

类别 变量保存什么 典型例子
值类型 类型的值本身;普通赋值复制值 int、float、bool、char
引用类型 对对象的引用;普通赋值复制引用 string、object、普通类

不要机械记成“值类型一定在栈上、引用类型一定在堆上”。实际存放位置还取决于字段、数组、装箱等上下文;入门时先理解复制的是值,还是对象的引用。值类型说明、引用类型说明

字符串是引用类型,但字符串对象不可变:

1
2
3
4
string first = "勇者";
string second = first;
second = "公主";
Console.WriteLine(first); // 勇者

这里是让 second 指向另一个字符串,不是修改原字符串对象的内容。

2.8 命名规范

语法规则由编译器检查:名称不能以数字开头,不能包含空格或普通的减号;C# 区分大小写。关键字通常不能直接作名称,可用 @ 转义,但普通业务命名不必刻意这样做。

1
2
3
4
5
int heroHp = 100;
int HeroHp = 80; // 语法上是另一个变量,但不建议只用大小写区分含义。
int @class = 1; // 合法,但通常应该换一个更清晰的名字。
// int 1hp = 100; // 非法。
// int hero-hp = 100; // 非法。

风格约定帮助协作,但并非每一项都是语言强制规定。

对象 本教程采用的风格 示例
局部变量、参数 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
2
3
4
5
6
7
8
const int MaxHp = 100;
const string GameName = "拯救公主";
const char HeroSymbol = 'H';
const int ScreenHeight = 32;
const int GameHeight = ScreenHeight * 3 / 4;

Console.WriteLine(GameName);
Console.WriteLine(GameHeight); // 24

基本形式:

1
2
// const 类型 常量名 = 常量表达式;
const int InitialHp = 100;

3.2 常量的特点

  1. 必须在声明时初始化。
  2. 初始化表达式必须能在编译阶段求值。
  3. 声明后不能再次赋值,也不能进行 ++、-- 等修改操作。
  4. 可以由其他常量组成表达式。
  5. 可以在方法内定义局部常量,也可以在类中定义常量字段。
  6. const 字段不能再加 static;它本身就不依赖某个对象实例。
1
2
3
4
5
6
const int MaxHp = 100;
const int HalfHp = MaxHp / 2;

// MaxHp = 200; // 错误:不能修改常量。
// const int Missing; // 错误:缺少初始化。
// const int CurrentYear = DateTime.Now.Year; // 错误:依赖运行时。

常见可用类型包括数值类型、bool、char、string 和枚举类型。不能用 const Random random = new Random(); 来创建随机数对象;除字符串的特殊规则外,引用类型常量只能是 null。const 说明

3.3 常量与变量如何配合

1
2
3
4
5
const int MaxHp = 100;
int heroHp = MaxHp;
heroHp = heroHp - 25;

Console.WriteLine($"当前血量:{heroHp}/{MaxHp}");

MaxHp 是固定规则;heroHp 是随着战斗变化的当前状态。把二者分开,可以减少散落在代码中的不明数字。

这里的 $"...{heroHp}..." 是字符串插值,第五章会详细讲解。

3.4 提前认识 readonly

第十章会看到下面的类字段声明,它应放在 Program 类内部、Main 方法外部:

1
private static readonly Random random = new Random();
对比 const readonly 字段
值何时确定 编译时 可以在运行时初始化
可否创建普通对象 不能用 new Random() 初始化 可以
赋值位置 声明处 声明处或对应构造函数中
引用类型的含义 除字符串外通常只能是 null 常量 固定字段保存的引用,不保证对象内容不变

readonly Random 仍然可以调用 Next();固定的是这个字段不能随意改成另一个 Random 对象,并不是冻结随机数发生器的内部状态。局部变量不能直接照搬这种 readonly 字段声明。

3.5 第三章练习

定义地图总高度为 32,游戏区高度为总高度的四分之三,日志区高度为剩余部分。输出三个结果,说明为什么这些值适合用常量。

第四章:转义字符

4.1 为什么需要转义

普通字符串使用双引号作为边界。如果内容本身也要有双引号,或要包含换行、制表符,就需要特殊写法。

1
2
3
Console.WriteLine("公主说:\"谢谢你!\"");
Console.WriteLine("第一行\n第二行");
Console.WriteLine("C:\\Games\\RescuePrincess");

普通字符串中的反斜杠 \ 用来引入转义序列。源代码里的两个字符 \n,在字符串中表示一个换行控制字符。

4.2 常用转义字符

写法 含义 示例
\n 换行,LF "第一行\n第二行"
\r 回车,CR,通常使光标回到当前行开头 "Loading\rDone"
\t 水平制表符,移动到下一个制表位 "姓名\t血量"
\\ 一个反斜杠 "C:\\Games"
\" 一个双引号 "她说:\"你好\""
\' 一个单引号,常用于字符字面量 char quote = '\'';
1
2
3
4
5
6
7
8
Console.WriteLine("名称\t血量");
Console.WriteLine("勇者\t100");
Console.WriteLine("怪兽\t100");

char slash = '\\';
char quote = '\'';
Console.WriteLine(slash);
Console.WriteLine(quote);

\t 不是固定数量的空格,中文宽度、字体和终端设置也会影响对齐。游戏地图中需要精确定位时,使用固定宽度的英文字符更容易控制。

\r 与 \n 的含义不同;Windows 文本常使用 \r\n 换行,Unix 风格文本常使用 \n。希望使用当前平台的换行符时,可以写:

1
Console.Write("第一行" + Environment.NewLine + "第二行");

一般输出一行文字直接使用 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
2
3
Console.WriteLine("\u4E2D\u6587"); // 中文
Console.WriteLine("\U0001F600"); // 是否显示表情还取决于字体。
Console.WriteLine("A\0B".Length); // 3:中间的空字符属于字符串内容。

\x 的陷阱:

1
2
3
4
string first = "\x41B";   // 会尽量读取十六进制数字,表示 U+041B。
string second = "\u0041B"; // 固定四位:A 后面再接 B,即 AB。
Console.WriteLine(first);
Console.WriteLine(second);

需要可读、稳定的 Unicode 转义时,优先使用固定长度的 \u 或 \U。正文的项目将语言版本设为 C# 12,因此不会使用 \e 语法。字符串与转义说明

4.4 逐字字符串:@"..."

路径中反斜杠很多时,可以使用逐字字符串,反斜杠不再引入普通转义:

1
2
3
4
5
6
7
8
string path1 = "C:\\Games\\RescuePrincess";
string path2 = @"C:\Games\RescuePrincess";
Console.WriteLine(path1 == path2); // True

string message = @"公主说:""谢谢你!""";
Console.WriteLine(message); // 公主说:"谢谢你!"

Console.WriteLine(@"第一行\n第二行"); // 显示字面的 \n,不会因此换行。

逐字字符串中,想表示一个双引号要写两个双引号 ""。

@ 还可与插值组合;第五章介绍 $ 后,可以理解 $@"C:\Games\{gameName}" 这种形式。

4.5 原始字符串:了解即可

C# 11 起可以使用至少三个双引号包围原始字符串,适合带有很多引号的文本:

1
2
3
4
5
string text = """
公主说:"谢谢你!"
路径:C:\Games\RescuePrincess
""";
Console.WriteLine(text);

多行原始字符串的起始、结束分隔符需要按规则单独占行,结束分隔符的缩进也会影响内容的缩进。入门时先熟悉普通字符串与逐字字符串,再按需使用原始字符串。

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
2
3
4
5
6
int score = 100;
long largeScore = score;
double scoreAsDouble = score;

float speed = 3.5f;
double preciseContainer = speed;

入门时常见的方向:较小范围的整数转为能容纳它的整数类型、整数转为浮点类型、float 转为 double。但不能把规则简单背成“字节小的一定能隐式转成字节大的”。

1
2
3
4
5
6
int signed = 10;
// uint unsigned = signed; // 仍然错误:int 类型本身也可能为负数。

byte small = 100; // 允许:100 是编译时已知、且在 byte 范围内的 int 常量表达式。
int source = 100;
// byte other = source; // 不允许:不能套用上面的常量表达式规则。

隐式转换也可能损失精度。 例如某些 int 到 float 的转换允许隐式进行,但目标类型无法精确表示所有整数:

1
2
3
4
int original = 16_777_217;
float approximate = original;
int recovered = (int)approximate;
Console.WriteLine(recovered); // 16777216

隐式转换意味着“语言允许不写转换符”,并不意味着所有情况下都能保留原数值的每一位信息。数值转换规则

5.3 显式转换:写明目标类型

基本形式是 (目标类型)表达式。

1
2
3
4
double value = 3.9;
int whole = (int)value;
Console.WriteLine(whole); // 3
Console.WriteLine((int)-3.9); // -3

在范围有效的情况下,浮点数转整数会向零截断,不是四舍五入,也不是总向下取整。

转换超出范围时,不能依赖一个“看起来合理”的结果。整数之间的显式转换可用 checked 检查溢出:

1
2
3
4
5
int large = 300;
byte wrapped = unchecked((byte)large);
Console.WriteLine(wrapped); // 44:显式采用不检查溢出的上下文。

// byte safe = checked((byte)large); // 运行时抛出 OverflowException。

unchecked 只是用来说明规则,不建议把它当成处理输入错误的办法。浮点数转换为整数时,若超出范围,checked 会抛出异常;unchecked 下某些结果未被语言规定,不要依赖。decimal 转整数超出范围时也会抛出异常。

布尔类型与数值类型之间没有普通的强制转换:

1
2
// int number = (int)true; // 错误。
// bool flag = (bool)1; // 错误。

5.4 Parse:把符合格式的字符串解析为值

1
2
3
4
5
6
7
8
int hp = int.Parse("100");
long score = long.Parse("9000000000");
double speed = double.Parse("3.5", System.Globalization.CultureInfo.InvariantCulture);
bool isAlive = bool.Parse("true");
char symbol = char.Parse("H");

Console.WriteLine(hp);
Console.WriteLine(speed);

Parse 不是所有类型都按同一套格式理解文字:int.Parse("3.5") 不会帮你取整,bool.Parse("1") 也不会当成 true。

1
2
3
// int.Parse("abc");        // FormatException:格式错误。
// int.Parse("999999999999999999"); // OverflowException:超出 int 范围。
// int.Parse((string)null!); // ArgumentNullException:空引用输入。

上面的 null! 只用于展示异常,! 不会把 null 变成有效字符串;它只是抑制可空分析警告。实际代码不应该用它掩盖输入缺失。

小数点、日期等文本格式可能受当前区域文化影响。示例指定 InvariantCulture 来稳定使用 . 作为小数点;交互软件则应根据面向的用户决定使用哪一种文化规则。

5.5 TryParse:用户输入优先考虑它

用户输错数字很常见,不必把每次输错都当成异常处理。

1
2
3
4
Console.Write("请输入血量:");
string? input = Console.ReadLine();
bool success = int.TryParse(input, out int hp);
Console.WriteLine($"转换成功:{success},结果:{hp}");

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
2
3
4
5
6
7
int fromText = Convert.ToInt32("100");
double fromInteger = Convert.ToDouble(100);
int fromBool = Convert.ToInt32(true);

Console.WriteLine(fromText); // 100
Console.WriteLine(fromInteger); // 100
Console.WriteLine(fromBool); // 1

Convert 不是“万能安全转换”,也可能因为格式、溢出或不支持的类型而抛出异常。判断行为时,需要看调用的是哪个重载。

对 double 转 int,Convert.ToInt32 与强制转换的取整规则不同:

1
2
3
4
Console.WriteLine((int)3.9);             // 3:向零截断。
Console.WriteLine(Convert.ToInt32(3.9)); // 4:取最近的整数。
Console.WriteLine(Convert.ToInt32(2.5)); // 2:恰好在中点时取偶数。
Console.WriteLine(Convert.ToInt32(3.5)); // 4:恰好在中点时取偶数。

因此不要把 Convert.ToInt32(double) 简单记成普通“四舍五入”。Convert.ToInt32 说明

对于明确传入的 string?:

1
2
3
4
string? missing = null;
Console.WriteLine(Convert.ToInt32(missing)); // 0
// int.Parse(missing!); // 会抛出 ArgumentNullException。
// Convert.ToInt32(""); // 空字符串会抛出 FormatException,不是 0。

Convert 对空引用的某些处理可能把“用户没有输入”变成一个合法数值;需要区分这两种情况时,仍然更适合使用 TryParse。

5.7 字符串拼接、插值与 ToString()

第一种方式是 + 拼接:

1
2
3
4
int heroHp = 100;
int maxHp = 100;
string text = "勇者血量:" + heroHp + "/" + maxHp;
Console.WriteLine(text); // 勇者血量:100/100

第二种是字符串插值,通常更清楚:

1
2
Console.WriteLine($"勇者血量:{heroHp}/{maxHp}");
Console.WriteLine($"受到 20 点伤害后:{heroHp - 20}");

第三种是显式调用 ToString():

1
2
3
4
int score = 120;
string scoreText = score.ToString();
double ratio = 0.756;
Console.WriteLine(ratio.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)); // 0.76

F2 表示显示为两位小数,改变的是格式化得到的文字,不会改变原始数值。

特别留意运算顺序:

1
2
Console.WriteLine("结果:" + 1 + 2);   // 结果:12
Console.WriteLine("结果:" + (1 + 2)); // 结果:3

字符串拼接不是在把 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
try
{
Console.Write("请输入一个整数:");
string? input = Console.ReadLine();
int number = int.Parse(input ?? "");
Console.WriteLine($"解析成功:{number}");
}
catch (FormatException ex)
{
Console.WriteLine("请输入有效的整数,例如 100。");
Console.WriteLine($"技术信息:{ex.Message}");
}
catch (OverflowException)
{
Console.WriteLine("数字太大或太小,超过了 int 的范围。");
}
finally
{
Console.WriteLine("本次输入处理结束。");
}

其中 input ?? "" 表示:若 input 是 null 就使用空字符串,否则使用 input。?? 是空合并运算符,它与三元条件运算符 ?: 不同。

执行顺序:

  1. 先执行 try。
  2. 若没有异常,跳过各个 catch。
  3. 若某句抛出异常,try 中它后面的语句不再继续执行,寻找第一个匹配的 catch。
  4. 有 finally 时,在正常控制流离开这组结构前执行它,包括通过 return 离开的情况。
  5. 若异常没有被处理,它会继续向调用方传播。

finally 常用来恢复状态或释放资源;不能把它理解成“无论操作系统强制结束进程还是断电都一定执行”。异常处理说明

6.3 捕获顺序与异常对象

catch 从上往下匹配,具体异常要放在较宽泛的基类异常前面。

1
2
3
4
5
6
7
8
9
try
{
int number = int.Parse("abc");
Console.WriteLine(number);
}
catch (FormatException ex)
{
Console.WriteLine(ex.Message);
}

ex 是异常对象,Message 提供说明,StackTrace 可帮助开发时定位调用路径。面向最终用户通常显示简洁提示;开发时再查看详细异常信息。

不要写空的 catch 把问题无声吞掉,也不要把整个程序都包进一个捕获所有异常、却没有合理处理的代码块。

6.4 常见异常

异常 常见原因 处理思路
FormatException 文本格式不符合要求 提示正确格式,或改用 TryParse
OverflowException 数值超出目标类型范围 检查范围、选择合适类型
DivideByZeroException 整数或 decimal 除以零 先检查除数
ArgumentNullException 某参数不允许为 null 检查输入来源和 API 约定
ArgumentOutOfRangeException 参数超出合法范围 检查边界,例如控制台光标坐标
IOException 输入输出设备或文件操作失败 给出提示,按需要重试或退出
NullReferenceException 对 null 访问成员 修复空值来源,不能只靠捕获掩盖问题

补充:double、float 的除零不一定抛异常,可能得到 Infinity 或 NaN;它们的规则与整数除法不同。

1
2
3
double zero = 0;
Console.WriteLine(1.0 / zero); // Infinity
Console.WriteLine(0.0 / zero); // NaN

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
2
3
4
5
6
double first = 7 / 2;
double second = 7.0 / 2;
double third = (double)7 / 2;
Console.WriteLine(first); // 3
Console.WriteLine(second); // 3.5
Console.WriteLine(third); // 3.5

整数除法向零截断;取余结果的符号与左操作数一致,或结果为零:

1
2
3
Console.WriteLine(-7 / 3); // -2
Console.WriteLine(-7 % 3); // -1
Console.WriteLine(7 % 3); // 1

常见用途:turn % 2 == 0 判断偶数回合,count % pageSize 计算余数。不要把 % 当成“百分比运算符”。算术运算符说明

7.2 赋值与复合赋值

1
2
3
4
5
6
7
8
9
10
11
12
int heroHp = 100;
heroHp = heroHp - 20;
heroHp -= 10;

int score = 0;
score += 100;
score *= 2;
score /= 4;
score %= 30;

Console.WriteLine(heroHp); // 70
Console.WriteLine(score); // 20

对本教程中的简单 int 变量,heroHp -= 10 可以理解成 heroHp = heroHp - 10。对于更复杂的左侧表达式或涉及类型提升的情况,两者不能在所有细节上机械等同。

例如小整数参与加法时通常提升为 int:

1
2
3
4
byte a = 10;
byte b = 20;
int sum = a + b;
// byte sum2 = a + b; // 错误:加法结果是 int。

7.3 前置与后置自增

1
2
3
4
5
6
7
int a = 1;
int b = a++;
Console.WriteLine($"a={a}, b={b}"); // a=2, b=1

int c = 1;
int d = ++c;
Console.WriteLine($"c={c}, d={d}"); // c=2, d=2
写法 在表达式中产生什么值
a++ 先使用旧值,再让 a 加一
++a 先让 a 加一,再使用新值

单独写成一条语句时,a++; 与 ++a; 都会让 a 增加一。初学时避免在同一个复杂表达式中多次修改同一个变量。

7.4 比较运算符:产生条件结果

有些入门提纲把它们统称为“条件运算符”,但更准确的分类是相等运算符和关系运算符。C# 通常所说的“条件运算符”是后面的 ?:。

运算符 含义 示例
== 相等 heroHp == 100
!= 不相等 heroHp != 0
> 大于 heroHp > 0
< 小于 heroHp < 30
>= 大于或等于 score >= 60
<= 小于或等于 heroHp <= 0
1
2
3
4
5
int heroHp = 100;
bool isAlive = heroHp > 0;
bool isFullHp = heroHp == 100;
Console.WriteLine(isAlive); // True
Console.WriteLine(isFullHp); // True

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
2
3
4
5
6
7
int divisor = 0;
bool valid = divisor != 0 && 10 / divisor > 1;
Console.WriteLine(valid); // False,不会执行右侧除法。

string? name = null;
bool hasName = name != null && name.Length > 0;
Console.WriteLine(hasName); // False,不会对 null 访问 Length。

如果把第一句的 && 改成 &,右侧仍然会执行,可能抛出整数除零异常。常规条件组合优先使用 &&、||。布尔逻辑运算符说明

判断闭区间时不能直接照抄数学形式:

1
2
3
int hp = 80;
bool inRange = hp >= 0 && hp <= 100;
// bool wrong = 0 <= hp <= 100; // 错误:左半部分已经得到 bool。

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
2
Console.WriteLine(-8 >> 1);  // -4:int 的算术右移。
Console.WriteLine(-8 >>> 1); // 2147483644:高位补零。

对正整数、且不发生位丢失等问题时,左移一位常对应乘二,右移一位常对应除二。但移位可能丢弃高位,负数右移的规则也不同,不能把它当成所有场景下的普通乘除法替代品。位运算说明

状态标记示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
const int HasSword = 1 << 0;  // 1
const int HasShield = 1 << 1; // 2
const int HasKey = 1 << 2; // 4

int inventory = 0;
inventory |= HasSword; // 添加剑。
inventory |= HasKey; // 添加钥匙。

bool hasKey = (inventory & HasKey) != 0;
Console.WriteLine(hasKey); // True

inventory &= ~HasKey; // 移除钥匙。
inventory ^= HasShield; // 切换盾牌状态:没有则添加,有则移除。

同一个 & 用于 bool 时是逻辑与,用于整数时是按位与,理解时先看操作数类型。

7.7 三目运算符,也叫三元条件运算符

1
2
3
4
// 条件 ? 条件为真时的表达式 : 条件为假时的表达式
int heroHp = 20;
string status = heroHp > 0 ? "存活" : "死亡";
Console.WriteLine(status); // 存活

它有三个操作数,作用是根据条件选择一个值。条件为真只计算 ? 后的分支,条件为假只计算 : 后的分支。两个分支需要能形成一个合法的结果类型。条件运算符说明

游戏中可用它限制血量不低于零:

1
2
3
4
int hp = 10;
int damage = 25;
hp = hp - damage < 0 ? 0 : hp - damage;
Console.WriteLine(hp); // 0

也可以使用 Math.Max(0, hp - damage)。完整游戏采用限制实际伤害不超过剩余血量的方式,效果同样是血量最低为零。

7.8 字符串中的 + 与数值中的 +

1
2
3
4
int damage = 20;
Console.WriteLine(10 + damage); // 30
Console.WriteLine("伤害:" + damage); // 伤害:20
Console.WriteLine("1" + "2"); // 12,而不是 3。

同一个符号在不同类型上有不同含义:数字相加,字符串拼接。代码中混合数字与字符串时,先用括号明确运算顺序,或直接使用字符串插值:

1
2
int damage = 20;
Console.WriteLine($"总伤害:{10 + damage}");

重复产生很长字符串时,可在后续课程学习 StringBuilder。本教程为了集中练习基础语法,在固定的小型画面中使用简单拼接。

7.9 常用优先级与溢出

下面只列出本教程常用的一部分优先级,从高到低排列:

层级 运算符
1 括号内的子表达式先计算
2 一元运算,如 !、~、前置 ++、显式转换
3 *、/、%
4 +、-
5 <<、>>、>>>
6 <、>、<=、>=
7 ==、!=
8 按位/布尔 &,然后 ^,然后 |
9 &&,然后 ||
10 ?:
11 =、+=、-= 等赋值运算符

不必靠复杂表达式展示记忆力;只要阅读上可能有歧义,就加括号。

1
2
3
4
5
6
7
int heroHp = 100;
int monsterHp = 100;
int baseAttack = 10;
int weaponAttack = 10;
bool canAttack = (heroHp > 0) && (monsterHp > 0);
int damage = (baseAttack + weaponAttack) * 2;
Console.WriteLine($"可以攻击:{canAttack},伤害:{damage}");

这里先算括号中的 10 + 10,再乘以 2,最终伤害为 40。

整数运算也可能溢出:

1
2
3
int max = int.MaxValue;
Console.WriteLine(unchecked(max + 1)); // -2147483648
// Console.WriteLine(checked(max + 1)); // 抛出 OverflowException。

7.10 第七章练习

把 100 点血量扣除 130 点伤害,并限制最低为零。再定义一个值为 1 << 2 的钥匙标记,完成添加、检查和移除三个操作。

第八章:条件分支语句

8.1 if:满足条件才执行

1
2
3
4
5
int heroHp = 20;
if (heroHp <= 30)
{
Console.WriteLine("血量较低,小心战斗!");
}

圆括号中的表达式必须得到 bool。大括号中的语句只在条件为真时执行。

1
// if (heroHp) { } // 错误:int 不能直接当作 bool。

即使只执行一行,也建议保留大括号,便于后续修改。

8.2 if-else:二选一

1
2
3
4
5
6
7
8
9
int heroHp = 0;
if (heroHp > 0)
{
Console.WriteLine("玩家还活着。");
}
else
{
Console.WriteLine("游戏结束。");
}

两个分支只会执行一个。

8.3 if-else if-else:按顺序选择

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int score = 85;
if (score < 0 || score > 100)
{
Console.WriteLine("分数超出范围。");
}
else if (score >= 90)
{
Console.WriteLine("优秀");
}
else if (score >= 60)
{
Console.WriteLine("及格");
}
else
{
Console.WriteLine("继续练习");
}

命中一个分支后,不会继续执行后面的 else if。因此有包含关系的条件通常需要从更严格的条件开始判断。

1
2
3
4
5
6
7
8
9
int score = 95;
if (score >= 60)
{
Console.WriteLine("及格");
}
else if (score >= 90)
{
Console.WriteLine("优秀"); // 95 已在前面命中,无法走到这里。
}

多个独立的 if 则可能分别命中,适用于多件互不排斥的事情。

8.4 嵌套与提前结束

嵌套分支是一个分支里再进行一次判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
bool isAlive = true;
bool hasKey = false;
if (isAlive)
{
if (hasKey)
{
Console.WriteLine("打开城门。");
}
else
{
Console.WriteLine("先找到钥匙。");
}
}

如果只有一个联合条件,写 if (isAlive && hasKey) 往往更直接。方法中也常用 return 提前结束无效操作,第十章会展示“距离过远就直接返回”的写法。

注意不要在 if (...) 后误写分号:

1
2
3
4
5
// 错误示意:if (heroHp > 0); 后面的空语句已经构成 if 的语句体。
// if (heroHp > 0);
// {
// Console.WriteLine("这块代码会无条件执行。");
// }

8.5 switch:根据不同取值执行操作

当一个值有多个离散选项时,switch 很适合表达菜单和指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Console.Write("输入 1 开始游戏,2 查看说明,0 退出:");
string command = Console.ReadLine() ?? "";

switch (command)
{
case "1":
Console.WriteLine("开始游戏。");
break;
case "2":
Console.WriteLine("WASD 移动,空格互动。");
break;
case "0":
Console.WriteLine("退出游戏。");
break;
default:
Console.WriteLine("没有这个选项。");
break;
}
关键字 作用
switch (command) 选择用于匹配的值
case "1": 某个匹配选项,末尾是冒号
break; 结束当前 switch 的执行
default: 没有其他匹配项时的处理,可按需求省略

C# 不允许一个执行完的非空 case 直接落入下一个 case。分支通常以 break、return 或 throw 等结束控制流的方式离开。

如果多个选项共用一段逻辑,可以把空标签连写:

1
2
3
4
5
6
7
8
9
10
11
char key = 'W';
switch (key)
{
case 'w':
case 'W':
Console.WriteLine("向上移动。");
break;
default:
Console.WriteLine("其他按键。");
break;
}

switch 还支持更丰富的模式匹配;当前先掌握普通分支即可。选择语句说明

8.6 三元表达式与 if-else 的关系

下面两种写法表达相同的简单取值逻辑:

1
2
3
4
5
6
7
8
9
10
11
int hp = 80;
string status;
if (hp > 0)
{
status = "存活";
}
else
{
status = "死亡";
}
Console.WriteLine(status);
1
2
3
int hp = 80;
string status = hp > 0 ? "存活" : "死亡";
Console.WriteLine(status);
要表达的事情 选择
从两个简短值中选一个 ?:
满足条件后执行多条语句 if / if-else
同一个值对应多个菜单选项 switch
多个复杂、不规则的条件范围 if-else if

?: 是表达式,不是与 if 并列的独立分支语句。不要为了缩短行数而堆叠很多层嵌套三元表达式。

8.7 输入判断综合示例

现在可以把第五章的 TryParse 与分支结合起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Console.Write("请输入初始血量(1~100):");
string? input = Console.ReadLine();

if (!int.TryParse(input, out int hp))
{
Console.WriteLine("输入不是有效整数。");
}
else if (hp < 1 || hp > 100)
{
Console.WriteLine("血量必须在 1~100 之间。");
}
else
{
Console.WriteLine($"玩家初始血量:{hp}");
}

成功解析为整数和满足业务范围是两个检查:-10 能解析成 int,但不符合这个游戏的初始血量规则。

8.8 第八章练习

读取菜单选项:1 显示“开始游戏”,2 显示“查看帮助”,其他输入显示“无效选项”。再用 if 判断血量:小于等于零显示死亡,1~30 显示危险,31 以上显示正常。

第九章:循环语句

9.1 while:先判断,再执行

1
2
3
4
5
6
int count = 1;
while (count <= 5)
{
Console.WriteLine($"第 {count} 次训练");
count++;
}

执行顺序:判断 count <= 5,为真就执行循环体,再回到条件处。条件第一次就是假时,循环体一次也不会执行。


flowchart TD
    A[初始化变量] --> B{"条件为真?"}
    B -->|是| C[执行循环体]
    C --> D[更新状态]
    D --> B
    B -->|否| E[执行循环后语句]

示例中的 count++ 会逐步让条件变为假。如果忘记更新变量,可能产生无限循环:

1
2
3
4
5
6
// 错误示意:count 始终不变。
// int count = 1;
// while (count <= 5)
// {
// Console.WriteLine(count);
// }

9.2 do...while:至少执行一次

1
2
3
4
5
6
7
int count = 10;
do
{
Console.WriteLine(count);
count++;
}
while (count <= 5);

虽然初始值 10 已经不满足条件,循环体仍然先执行一次,输出 10。注意 while (...) 后有一个分号。

它适合“先做一次,再判断是否重来”的场景,例如先生成一个随机位置,再检查是否与怪兽重叠。

9.3 for:把计数过程写在一起

1
2
3
4
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"编号:{i}");
}

输出编号 0、1、2、3、4,共五次。

基本结构:

1
2
3
4
// for (初始化; 条件; 每轮结束后的更新)
// {
// 循环体
// }

执行顺序是:初始化一次 → 判断条件 → 执行循环体 → 更新 → 再判断条件。

1
2
3
4
5
6
int total = 0;
for (int number = 1; number <= 100; number++)
{
total += number;
}
Console.WriteLine(total); // 5050

for (int i = 0; i < 5; i++) 中的 i 只在这条 for 语句的作用域内有效。

9.4 三种循环如何选择

循环 最少执行次数 更适合的场景
while 0 不确定次数,按状态持续运行,例如游戏主循环
do...while 1 必须先尝试一次,例如随机生成合法坐标
for 0 次数或范围清晰,例如逐行逐列画地图

三者经常能互相改写,选最容易表达意图的即可。循环语句说明

9.5 break:提前结束最近一层循环

1
2
3
4
5
6
7
8
9
10
11
while (true)
{
Console.Write("输入 q 退出,其他内容继续:");
string? input = Console.ReadLine();
if (input == null || input == "q")
{
break;
}
Console.WriteLine($"你输入了:{input}");
}
Console.WriteLine("循环结束。");

这里同时处理 null,避免标准输入结束后永远读取不到新内容,却还一直重复循环。

while (true) 可以是合法设计,只要明确提供退出条件。第十章游戏每轮使用 ReadKey 等待按键,不会仅为了等待而高速空转。

9.6 continue:跳过本轮剩余部分

1
2
3
4
5
6
7
8
for (int number = 1; number <= 10; number++)
{
if (number % 2 != 0)
{
continue;
}
Console.WriteLine(number);
}

输出 2、4、6、8、10。continue 不是结束整个循环,而是开始下一轮:for 会先执行更新部分,再判断条件;while 则直接回到条件判断处。

因此在 while 中,更新变量要放在不会被意外跳过的位置:

1
2
3
4
5
6
7
8
9
10
int number = 0;
while (number < 5)
{
number++;
if (number == 3)
{
continue;
}
Console.WriteLine(number); // 1、2、4、5
}

9.7 嵌套循环:按行、按列绘制

1
2
3
4
5
6
7
8
9
10
11
12
13
const int Width = 10;
const int Height = 5;

for (int y = 0; y < Height; y++)
{
for (int x = 0; x < Width; x++)
{
bool isBorder = x == 0 || x == Width - 1 ||
y == 0 || y == Height - 1;
Console.Write(isBorder ? '#' : ' ');
}
Console.WriteLine();
}

外层控制行,内层控制这一行的列。边界条件通过 || 组合:位于最左、最右、最上或最下的任意一条边时,输出边框字符。

嵌套循环里的 break 只退出最近的一层循环;如果它写在内层 switch 中,则只退出该 switch。不要把它和“退出整个程序”混为一谈。

9.8 简化战斗练习

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int heroHp = 100;
int monsterHp = 100;
int turn = 0;

while (heroHp > 0 && monsterHp > 0)
{
turn++;
monsterHp = Math.Max(0, monsterHp - 25);
Console.WriteLine($"第 {turn} 回合,怪兽剩余 {monsterHp} 点血量。");

if (monsterHp == 0)
{
break; // 怪兽死亡后不能再反击。
}

heroHp = Math.Max(0, heroHp - 20);
Console.WriteLine($"玩家剩余 {heroHp} 点血量。");
}

Console.WriteLine(heroHp > 0 ? "玩家获胜" : "玩家失败");

这一版使用固定伤害、自动连续执行,用于观察循环与退出顺序。下一章会改成“每按一次空格进行一个回合”,并加入随机伤害。

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 完整程序:演示定义方法、传入参数和返回结果。
using System;

internal class Program
{
private static void Main()
{
int hp = ApplyDamage(100, 20);
Console.WriteLine(hp); // 80
}

private static int ApplyDamage(int currentHp, int damage)
{
return Math.Max(0, currentHp - damage);
}
}
语法 当前需要理解的内容
private 这个方法或字段在当前类内部使用
static 本项目直接通过同一组静态方法、字段组织程序,不必先创建 Program 对象
void 方法不返回结果
int、bool、string 作为返回类型 方法调用后会产生相应类型的结果
(int currentHp, int damage) 调用方传入的数据,称为参数
return 值; 结束方法并返回结果
return; 提前结束一个 void 方法

字段声明在类内部、方法外部。游戏把当前血量、坐标等保存为字段,多个方法就能共同访问它们。

1
2
3
// 位置示意:下面两行应在 Program 类里、Main 外面。
private static int heroHp;
private static bool princessFollowing;

字段具有默认值:int 默认为 0,bool 默认为 false,引用类型默认为 null。不过每局开始时仍应明确重置状态,避免上一局的数据影响下一局。

本项目不引入数组、集合、自定义事件、继承等额外主题。五条日志暂时使用五个字符串字段保存,等学习数组或队列后可以再重构。

10.5 随机数、按键和字符串辅助 API

随机数:

1
2
3
Random random = new Random();
int damage = random.Next(10, 26); // 包含 10,不包含 26,即 10~25。
Console.WriteLine(damage);

Next(min, max) 的范围是左闭右开 [min, max)。需要覆盖 1~70 时应该写 Next(1, 71)。创建一个发生器并重复使用即可,不需要每次攻击都重新创建对象。本项目中的随机数用于游戏规则,不用于安全用途。Random.Next 说明

按键:

1
2
3
4
5
ConsoleKey key = Console.ReadKey(intercept: true).Key;
if (key == ConsoleKey.Spacebar)
{
Console.WriteLine("执行互动。");
}

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
2
3
4
5
// 算法片段:下面的坐标与边界变量由游戏状态提供。
int nextX = heroX + dx;
int nextY = heroY + dy;
bool inside = nextX >= MapLeft && nextX <= MapRight &&
nextY >= MapTop && nextY <= MapBottom;

确认 inside,再检查怪兽、公主等占位规则,最后提交坐标变化。无效操作会提前 return。

二、四向相邻用曼哈顿距离。

1
2
// 算法片段:距离为 1,代表恰好是四向相邻格。
bool adjacent = Math.Abs(heroX - monsterX) + Math.Abs(heroY - monsterY) == 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
2
dotnet build
dotnet run

自己从零创建:

1
2
dotnet new console -n RescuePrincess --framework net10.0
cd RescuePrincess

把下一节完整代码替换进 Program.cs,再运行 dotnet run。不要把它粘贴到另一个 Main 的内部。

配套项目的 RescuePrincess.csproj 为:

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

请在 Windows Terminal、PowerShell、命令提示符,或编辑器的集成终端运行。某些 IDE 的“输出”面板、重定向到文件的运行方式和没有交互终端的在线环境不支持 ReadKey 与光标定位。

10.8 完整代码: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
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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
using System;
using System.IO;

internal class Program
{
#region 地图与游戏数据
private const int Width = 72;
private const int Height = 32;
private const int GameHeight = Height * 3 / 4;
private const int MapLeft = 1;
private const int MapRight = Width - 2;
private const int MapTop = 3;
private const int MapBottom = GameHeight - 2;
private const int MonsterX = 54;
private const int MonsterY = 12;
private const int MaxHp = 100;

private static readonly Random random = new Random();
private static int scene; // 0:开始;1:游戏;2:结束。
private static int heroX, heroY, heroHp, monsterHp, turn;
private static int princessX, princessY, exitX, exitY;
private static bool princessFollowing, won;
private static string log1 = "", log2 = "", log3 = "", log4 = "", log5 = "";
#endregion

private static int Main()
{
// ReadKey 和定位绘制需要真实终端,不能使用输出面板或重定向。
if (Console.IsInputRedirected || Console.IsOutputRedirected)
{
Console.Error.WriteLine("Run this game in an interactive terminal.");
return 2;
}

try
{
Console.CursorVisible = false;
Console.Clear();
bool running = true;

while (running)
{
if (!WaitForTerminalSize())
break;

Draw();
ConsoleKey key = Console.ReadKey(intercept: true).Key;

switch (scene)
{
case 0:
if (key == ConsoleKey.Enter)
StartGame();
else if (key == ConsoleKey.Escape)
running = false;
break;

case 1:
HandleGameKey(key);
break;

case 2:
if (key == ConsoleKey.R)
StartGame();
else if (key == ConsoleKey.Enter)
scene = 0;
else if (key == ConsoleKey.Escape)
running = false;
break;
}
}
}
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;
}
finally
{
Console.CursorVisible = true;
Console.ResetColor();
Console.WriteLine();
}

return 0;
}

private static bool WaitForTerminalSize()
{
while (Console.WindowWidth < Width + 1 || Console.WindowHeight < Height + 1)
{
Console.Clear();
Console.WriteLine("Terminal must be at least 73 columns x 33 rows.");
Console.WriteLine("Resize, then press a key. ESC: quit.");
if (Console.ReadKey(intercept: true).Key == ConsoleKey.Escape)
return false;
Console.Clear();
}
return true;
}

private static void StartGame()
{
heroHp = MaxHp;
monsterHp = MaxHp;
turn = 0;
princessFollowing = false;
won = false;
princessX = MonsterX;
princessY = MonsterY;
exitX = 0;
exitY = 0;
log1 = log2 = log3 = log4 = log5 = "";

// 左闭右开:+1 才能让最右、最下的合法格子也有机会被抽到。
do
{
heroX = random.Next(MapLeft, MapRight + 1);
heroY = random.Next(MapTop, MapBottom + 1);
}
while (heroX == MonsterX && heroY == MonsterY);

AddLog("Find M. Stand next to it and press SPACE to attack.");
AddLog("WASD / arrows: move. H=Hero M=Monster P=Princess E=Exit.");
scene = 1;
}

private static void HandleGameKey(ConsoleKey key)
{
int dx = 0;
int dy = 0;

switch (key)
{
case ConsoleKey.W:
case ConsoleKey.UpArrow:
dy = -1;
break;
case ConsoleKey.S:
case ConsoleKey.DownArrow:
dy = 1;
break;
case ConsoleKey.A:
case ConsoleKey.LeftArrow:
dx = -1;
break;
case ConsoleKey.D:
case ConsoleKey.RightArrow:
dx = 1;
break;
case ConsoleKey.Spacebar:
Interact();
return;
case ConsoleKey.Escape:
scene = 0;
return;
default:
return;
}

MoveHero(dx, dy);
}

private static void MoveHero(int dx, int dy)
{
int nextX = heroX + dx;
int nextY = heroY + dy;

if (nextX < MapLeft || nextX > MapRight ||
nextY < MapTop || nextY > MapBottom)
{
AddLog("A wall blocks the way.");
return;
}

if (monsterHp > 0 && nextX == MonsterX && nextY == MonsterY)
{
AddLog("M blocks the way. Press SPACE from a neighboring cell.");
return;
}

if (monsterHp == 0 && !princessFollowing &&
nextX == princessX && nextY == princessY)
{
AddLog("Stand next to P, then press SPACE to start the escort.");
return;
}

int oldX = heroX;
int oldY = heroY;
heroX = nextX;
heroY = nextY;

// 只有成功移动,公主才走到玩家上一格;撞墙时二者都不动。
if (princessFollowing)
{
princessX = oldX;
princessY = oldY;
}

if (monsterHp == 0 && heroX == exitX && heroY == exitY)
{
if (princessFollowing)
{
won = true;
scene = 2;
}
else
{
AddLog("Rescue P first. Stand next to her and press SPACE.");
}
}
}

private static bool IsAdjacent(int x1, int y1, int x2, int y2)
{
return Math.Abs(x1 - x2) + Math.Abs(y1 - y2) == 1;
}

private static void Interact()
{
if (monsterHp > 0)
{
if (!IsAdjacent(heroX, heroY, MonsterX, MonsterY))
{
AddLog("Too far from M. Only the four neighboring cells count.");
return;
}
FightOneTurn();
}
else if (!princessFollowing)
{
if (!IsAdjacent(heroX, heroY, princessX, princessY))
{
AddLog("Find P and press SPACE from a neighboring cell.");
return;
}
princessFollowing = true;
AddLog("P is following you. Lead her to E to escape together!");
}
else
{
AddLog("P is already following you. Walk to E.");
}
}

private static void FightOneTurn()
{
turn++;
int heroDamage = Math.Min(random.Next(10, 26), monsterHp);
monsterHp -= heroDamage;
AddLog($"Turn {turn}: Hero deals {heroDamage}. Monster HP: {monsterHp}/{MaxHp}.");

// 怪兽死亡后立即结束本回合,不允许它再反击。
if (monsterHp == 0)
{
SpawnExit();
AddLog("M defeated! P appears where M stood. A random E appears.");
AddLog("Stand next to P and press SPACE, then lead her to E.");
return;
}

int monsterDamage = Math.Min(random.Next(10, 26), heroHp);
heroHp -= monsterDamage;
AddLog($"Monster deals {monsterDamage}. Hero HP: {heroHp}/{MaxHp}.");

if (heroHp == 0)
{
won = false;
scene = 2;
}
}

private static void SpawnExit()
{
do
{
exitX = random.Next(MapLeft, MapRight + 1);
exitY = random.Next(MapTop, MapBottom + 1);
}
while ((exitX == heroX && exitY == heroY) ||
(exitX == princessX && exitY == princessY));
}

private static void AddLog(string message)
{
// 本项目刻意只用五个字符串,学过数组/集合后可改为日志队列。
log1 = log2;
log2 = log3;
log3 = log4;
log4 = log5;
log5 = message;
}

private static string Border()
{
return "+" + new string('-', Width - 2) + "+";
}

private static string BoxLine(string text)
{
if (text.Length > Width - 2)
text = text.Substring(0, Width - 2);
return "|" + text.PadRight(Width - 2) + "|";
}

private static string MenuLine(int row)
{
if (row == 0 || row == Height - 1)
return Border();

string text = "";
if (scene == 0)
{
switch (row)
{
case 4: text = " RESCUE THE PRINCESS"; break;
case 7: text = " H = Hero M = Monster P = Princess E = Exit"; break;
case 10: text = " 1. Move with WASD or arrow keys."; break;
case 12: text = " 2. Stand beside M; SPACE plays one combat turn."; break;
case 14: text = " 3. Defeat M. Stand beside P and press SPACE to recruit her."; break;
case 16: text = " 4. P follows your previous cell. Walk to E to win."; break;
case 19: text = " Both start with 100 HP. Damage rolls are 10..25."; break;
case 22: text = " ENTER: start ESC: quit"; break;
case 25: text = " During play: ESC returns to this menu (current run is lost)."; break;
}
}
else
{
switch (row)
{
case 5: text = won ? " YOU WIN!" : " GAME OVER"; break;
case 9: text = won ? " You and the princess escaped together." : " The monster defeated you. Try another rescue."; break;
case 12: text = $" Hero HP: {heroHp}/{MaxHp} Monster HP: {monsterHp}/{MaxHp} Turns: {turn}"; break;
case 15: text = " Last combat message:"; break;
case 17: text = " " + log5; break;
case 21: text = " R: play again ENTER: main menu ESC: quit"; break;
}
}
return BoxLine(text);
}

private static string GameLine(int row)
{
if (row == 0 || row == 2 || row == GameHeight - 1 ||
row == GameHeight || row == Height - 1)
return Border();

if (row == 1)
{
string goal = monsterHp > 0 ? "FIGHT M" :
princessFollowing ? "ESCORT P TO E" : "RECRUIT P";
return BoxLine($" H:{heroHp,3}/{MaxHp} M:{monsterHp,3}/{MaxHp} Turn:{turn} Goal:{goal}");
}

if (row == GameHeight + 1)
return BoxLine(" BATTLE LOG (latest 5) | SPACE: interact | ESC: menu");

if (row > GameHeight + 1)
{
switch (row - GameHeight)
{
case 2: return BoxLine(log1);
case 3: return BoxLine(log2);
case 4: return BoxLine(log3);
case 5: return BoxLine(log4);
default: return BoxLine(log5);
}
}

string line = "|";
for (int x = MapLeft; x <= MapRight; x++)
{
char tile = ' ';
if (x == heroX && row == heroY)
tile = 'H';
else if (monsterHp > 0 && x == MonsterX && row == MonsterY)
tile = 'M';
else if (monsterHp == 0 && x == princessX && row == princessY)
tile = 'P';
else if (monsterHp == 0 && x == exitX && row == exitY)
tile = 'E';
line += tile;
}
return line + "|";
}

private static void Draw()
{
string frame = "";
for (int row = 0; row < Height; row++)
{
frame += scene == 1 ? GameLine(row) : MenuLine(row);
if (row < Height - 1)
frame += Environment.NewLine;
}
Console.SetCursorPosition(0, 0);
Console.Write(frame);
}
}

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、拼接、光标定位

不要一开始就试图背完全部代码。可以按以下顺序动手:

  1. 只运行菜单,确认回车能进入游戏、Esc 能返回。
  2. 阅读 StartGame 与 MoveHero,确认坐标和边界。
  3. 阅读 IsAdjacent、Interact 与 FightOneTurn,理解一个回合。
  4. 阅读 SpawnExit 和公主跟随部分,理解通关条件。
  5. 最后看字符串绘制,尝试修改状态栏文字或地图符号。

10.10 为什么这样处理几个细节

保持画面固定。 每一行统一为 72 个英文字符宽度,每次先把光标移回 (0, 0),再输出完整画面,不在每次按键后都 Clear()。这样可减少清屏带来的闪烁,也不会把不断增长的战斗文字写到地图下面。

最后一行不额外换行。 终端最后一行的换行可能导致整体滚动,因此 Draw 只在各行之间插入换行符。

显示的是实际扣血。 如果怪兽只剩 3 点血量,随机攻击值是 20,实际伤害显示为 3,血量变为零,不显示负数。

死亡判定立即结束相关流程。 怪兽死亡后 return,本回合不再反击;玩家死亡后切换结束场景,下一轮主循环便不再处理游戏移动。

护送必须显式触发。 击败怪兽只会生成公主,不会自动把她变成跟随状态;需要相邻再次按空格。这样救援与护送两个阶段能够清晰区分。

每局重新设置全部状态。 R 重新开始时,双方恢复 100 点血量,回合清零,护送与胜负标志复位,旧出口和旧日志清除,玩家重新随机出生。

恢复终端状态。 程序通过 finally 恢复光标显示并重置颜色。终端在绘制瞬间缩小时仍可能报告坐标异常,程序会提示调整后重新运行。

10.11 实际游玩步骤

  1. 调整终端到至少 73 列 × 33 行,运行程序。
  2. 在开始场景按回车,找到代表玩家的 H 与固定位置的 M。
  3. 用 WASD 移动到 M 的上、下、左或右侧。
  4. 按一次空格,查看下方玩家攻击与怪兽反击的记录。继续按空格,直到一方血量归零。
  5. 如果失败,在结束场景按 R 开始新一局。
  6. 如果获胜,M 的位置显示为 P,地图其他空地显示 E。
  7. 保持与 P 相邻,按空格,状态栏目标变为 ESCORT P TO E。
  8. 向 E 移动,观察 P 始终跟在上一格。走到出口后进入胜利场景。

两边伤害均随机,玩家有先手优势但不保证每局必胜。这使失败场景也具有实际意义。

10.12 验收清单

检查项 预期行为
连续开始多局 玩家位置有变化,怪兽固定;双方每局初始均为 100 HP
从四个方向尝试越界 不能进入边框、状态栏或日志区
走向存活怪兽所在格 被阻挡;提示在相邻位置按空格
远处或斜角按空格 不扣血、不增加回合
相邻按一次空格 玩家攻击一次;怪兽存活才反击一次
怪兽被斩杀 不反击;公主原地出现,随机出口不覆盖角色
玩家被打到零血 进入失败场景,血量不会变成负数
没接公主先走到出口 提示先救公主,不触发胜利
公主相邻按空格 开启跟随;重复空格不会重置公主位置
护送时走动、回头或撞墙 跟随玩家上一格;回头可交换位置;撞墙时都不动
护送走到出口 进入胜利场景
结束后按 R 新局恢复全部初始状态
上下画面区域 游戏区 24 行,日志区 8 行;日志最多显示最近五条
窗口过小 提示扩大终端,按键后重新检查

验证记录:配套源码已在 .NET 10 环境编译通过,编译警告与错误均为零;程序化检查覆盖了坐标范围、出生与出口重叠、四向攻击、伤害范围、击杀不反击、死亡、招募、跟随、出口判定、重开和固定画面尺寸。另外,已通过交互终端按键跑通战斗、招募、护送、胜利、重开及退出流程,并检查窗口过小时的恢复提示。真实终端的视觉效果还会受字体与窗口设置影响。

10.13 后续练习

掌握当前版本后,再逐步增加功能:

  • 把伤害范围变成开始菜单中的难度选项。
  • 增加一次性恢复药水,练习变量、范围限制和输入处理。
  • 使用数组或队列管理日志,替代五个字符串字段。
  • 为地图增加内部障碍物,同时保证出生点和出口可以到达。
  • 使用类或结构体管理角色坐标和血量,减少零散字段。
  • 迁移到 Unity 时,把输入、坐标、渲染分别换成引擎提供的功能;保留战斗与通关规则,并重新检查帧更新下的输入时机。

附录 A:练习参考答案

下面的短代码放入第一章的 Main 方法内运行,每题单独运行。

A.1 第一个应用程序

1
2
3
4
5
Console.WriteLine("拯救公主");
Console.WriteLine("WASD 移动,空格互动");
Console.WriteLine();
Console.Write("按任意键继续……");
Console.ReadKey(intercept: true);

WriteLine() 用于单独空一行,ReadKey(true) 用于等待并隐藏按键回显。

A.2 变量

1
2
3
4
5
6
7
8
9
10
11
string heroName = "勇者";
int heroHp = 100;
float moveSpeed = 3.5f;
bool hasKey = false;
char heroSymbol = 'H';

Console.WriteLine($"名字:{heroName}");
Console.WriteLine($"血量:{heroHp}");
Console.WriteLine($"速度:{moveSpeed}");
Console.WriteLine($"钥匙:{hasKey}");
Console.WriteLine($"符号:{heroSymbol}");

var hp = 100; 推断为 int,之后不能赋值为字符串 "100"。

A.3 常量

1
2
3
4
const int Height = 32;
const int GameHeight = Height * 3 / 4;
const int LogHeight = Height - GameHeight;
Console.WriteLine($"总高:{Height},游戏区:{GameHeight},日志区:{LogHeight}");

结果为 32、24、8。它们是当前版本的固定布局规则,能在编译时求值,所以适合使用常量。若以后允许玩家在设置中动态修改,就应考虑变量或运行时配置。

A.4 转义字符

1
2
3
Console.WriteLine("公主说:\"勇者,救救我!\"");
Console.WriteLine("C:\\Games\\RescuePrincess");
Console.WriteLine(@"C:\Games\RescuePrincess");

最后两句输出相同路径,第三句的反斜杠不需要写两次。

A.5 类型转换

1
2
3
4
5
6
Console.Write("请输入整数:");
bool success = int.TryParse(Console.ReadLine(), out int value);
Console.WriteLine($"成功:{success},数值:{value}");
Console.WriteLine((int)2.9); // 2
Console.WriteLine(Convert.ToInt32(2.9)); // 3
Console.WriteLine(Convert.ToInt32(2.5)); // 2

强制转换向零截断;Convert.ToInt32(double) 取最近的整数,恰好在中点时选择偶数。

A.6 异常捕获

第六章的 try-catch 示例运行结果为:

输入 行为
100 输出解析成功,然后执行 finally
abc 进入 FormatException 分支,然后执行 finally
很大的整数文本 进入 OverflowException 分支,然后执行 finally

缺少分号是编译错误,程序尚未运行,所以不可能进入运行时的 catch。

A.7 运算符

1
2
3
4
5
6
7
8
9
10
11
int hp = 100;
int damage = 130;
hp = Math.Max(0, hp - damage);
Console.WriteLine(hp); // 0

const int HasKey = 1 << 2;
int flags = 0;
flags |= HasKey;
Console.WriteLine((flags & HasKey) != 0); // True
flags &= ~HasKey;
Console.WriteLine((flags & HasKey) != 0); // False

用 & 取出对应标记后,应检查是否非零,而不是固定检查是否等于 1。钥匙标记本身是 4。

A.8 分支

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
Console.Write("输入菜单选项:");
switch (Console.ReadLine())
{
case "1":
Console.WriteLine("开始游戏");
break;
case "2":
Console.WriteLine("查看帮助");
break;
default:
Console.WriteLine("无效选项");
break;
}

int hp = 25;
if (hp <= 0)
{
Console.WriteLine("死亡");
}
else if (hp <= 30)
{
Console.WriteLine("危险");
}
else
{
Console.WriteLine("正常");
}

第二个条件不用重复写 hp >= 1,因为 hp <= 0 已经在前一个分支处理。

A.9 循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
for (int number = 1; number <= 20; number++)
{
if (number % 2 == 0)
{
Console.WriteLine(number);
}
}

while (true)
{
Console.Write("输入 q 退出:");
string? input = Console.ReadLine();
if (input == null || input == "q")
{
break;
}
Console.WriteLine($"收到:{input}");
}

出生点重试需要至少生成一次候选位置,再判断是否重叠,所以 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 随机数范围

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