17.总结
17.1 知识点

学习的主要内容

总结目的——养成良好的学习习惯

UML类图和流程图
面向对象的UML类图和面向过程的流程图同样重要。写代码之前可以画UML类图构思,写完代码后对比着UML类图回顾。
完成需求有千百种方式

练习

17.2 核心要点速览
多脚本文件管理
| 概念 | 说明 |
|---|---|
| 脚本文件 | .cs 后缀,一个类/接口/结构体对应一个脚本 |
| 解决方案 | .sln 后缀,双击进入 |
| 项目 | .sln 同级目录下的文件夹 |
| bin/Debug | 运行后生成,包含 app 文件夹和 exe |
| 命名空间 | 同命名空间可直接使用,不同需 using 引用 |
UML 类图
| 元素 | 表示 |
|---|---|
| 类 | 矩形框,分三层:类名、属性、方法 |
| 接口 | 顶部加 <<interface>> |
| 继承 | 空心三角 + 实线 |
| 实现 | 空心三角 + 虚线 |
| 关联 | 实线箭头 |
| 依赖 | 虚线箭头 |
面向对象七大原则
| 原则 | 核心思想 |
|---|---|
| 单一职责 | 一个类只做一件事 |
| 开闭原则 | 对扩展开放,对修改关闭 |
| 里氏替换 | 父类出现的地方子类可替代 |
| 依赖倒转 | 依赖抽象,不依赖具体 |
| 迪米特法则 | 最少知识,降低耦合 |
| 接口隔离 | 接口要小而专一 |
| 合成复用 | 优先组合而非继承 |
项目概述
目标:控制台贪食蛇游戏,包含开始、游戏、结束三个场景。
核心功能:
- 场景切换(开始 → 游戏 → 结束)
- 蛇移动(WASD 控制)
- 吃食物长身体
- 撞墙/撞身体结束
场景系统
| 类/接口 | 职责 |
|---|---|
ISceneUpdate |
帧更新接口,含 Update() 方法 |
Game |
游戏主类,管理场景切换、主循环 |
BeginOrEndBaseScene |
开始/结束场景基类,处理选项逻辑 |
BeginScene |
开始场景,选项:开始游戏/结束游戏 |
EndScene |
结束场景,选项:回到开始/结束游戏 |
GameScene |
游戏场景,管理地图、蛇、食物 |
游戏对象系统
| 类/接口 | 职责 |
|---|---|
IDraw |
绘制接口,含 Draw() 方法 |
Position |
位置结构体,含 x/y 坐标,重载 == != |
GameObject |
游戏对象基类(抽象),含 pos |
Wall |
墙壁类,红色 ■ |
Food |
食物类,青色 ¤,随机位置不与蛇重合 |
SnakeBody |
蛇身体类,头黄色 ●,身体绿色 ◎ |
Snake |
蛇类,管理身体数组、移动、吃食物 |
Map |
地图类,管理墙壁数组 |
游戏主循环
// Game 类
while (true)
{
if (nowScene != null)
nowScene.Update();
}
场景切换
public static void ChangeScene(E_SceneType type)
{
Console.Clear(); // 切换前清屏
switch (type)
{
case E_SceneType.Begin: nowScene = new BeginScene(); break;
case E_SceneType.Game: nowScene = new GameScene(); break;
case E_SceneType.End: nowScene = new EndScene(); break;
}
}
游戏降速
// 每 10000 帧更新一次,避免游戏过快
if (whileLoopTimes % 10000 == 0)
{
// 绘制、移动、检测...
whileLoopTimes = 0;
}
++whileLoopTimes;
非阻塞输入检测
// Console.KeyAvailable 判断是否有输入,不会阻塞
if (Console.KeyAvailable)
{
switch (Console.ReadKey(true).Key)
{
case ConsoleKey.W: snake.ChangeDir(E_MoveDir.Up); break;
case ConsoleKey.S: snake.ChangeDir(E_MoveDir.Down); break;
case ConsoleKey.A: snake.ChangeDir(E_MoveDir.Left); break;
case ConsoleKey.D: snake.ChangeDir(E_MoveDir.Right); break;
}
}
蛇移动逻辑
public void Move()
{
// 1. 擦除蛇尾(避免残影)
Console.SetCursorPosition(lastBody.pos.x, lastBody.pos.y);
Console.Write(" ");
// 2. 身体跟随:从尾到头,后一个 = 前一个位置
for (int i = snakeBodysLength - 1; i > 0; i--)
bodys[i].pos = bodys[i - 1].pos;
// 3. 蛇头移动
switch (dir)
{
case E_MoveDir.Up: --bodys[0].pos.y; break;
case E_MoveDir.Down: ++bodys[0].pos.y; break;
case E_MoveDir.Left: bodys[0].pos.x -= 2; break; // x 坐标每次移动 2
case E_MoveDir.Right: bodys[0].pos.x += 2; break;
}
}
方向切换限制
// 有身体时不能 180° 转向
if (snakeBodysLength > 1 &&
(this.dir == E_MoveDir.Up && dir == E_MoveDir.Down ||
this.dir == E_MoveDir.Down && dir == E_MoveDir.Up ||
this.dir == E_MoveDir.Left && dir == E_MoveDir.Right ||
this.dir == E_MoveDir.Right && dir == E_MoveDir.Left))
return;
食物随机位置
public void RandomPos(Snake snake)
{
Random r = new Random();
pos = new Position(r.Next(2, Game.w / 2 - 1) * 2, r.Next(1, Game.h - 4));
// 与蛇重合则递归重新生成
if (snake.CheckSamePos(pos))
RandomPos(snake);
}
开发流程建议
1. 搭建框架
├── 创建 Game 类、ISceneUpdate 接口
├── 实现主循环和场景切换
└── 创建 BeginScene、EndScene、GameScene 骨架
2. 实现开始/结束场景
├── 抽取 BeginOrEndBaseScene 基类
└── 实现选项切换和确认逻辑
3. 实现游戏对象
├── Position 结构体 + 运算符重载
├── IDraw 接口 + GameObject 基类
└── Wall、Food、SnakeBody 类
4. 实现地图
└── Map 类,四次遍历生成四周墙壁
5. 实现蛇
├── 身体数组管理
├── 移动 + 擦除残影
├── 方向切换 + 限制
└── 撞墙/撞身体检测
6. 实现食物
├── 随机位置(不与蛇重合)
└── 吃食物 + 长身体
7. 整合调试
└── GameScene 组装所有组件
17.3 面试题精选
基础题
1. 蛇移动逻辑的实现
题目
贪食蛇游戏中,蛇的移动逻辑是如何实现的?如何处理身体跟随和残影问题?
深入解析
蛇移动的三个步骤:
- 擦除蛇尾:移动前清除最后一个位置,避免残影
- 身体跟随:从尾到头遍历,每个身体等于前一个位置
- 蛇头移动:根据方向更新蛇头坐标
public void Move()
{
// 1. 擦除蛇尾(避免残影)
SnakeBody lastBody = bodys[snakeBodysLength - 1];
Console.SetCursorPosition(lastBody.pos.x, lastBody.pos.y);
Console.Write(" ");
// 2. 身体跟随:从尾到头,后一个 = 前一个位置
for (int i = snakeBodysLength - 1; i > 0; i--)
bodys[i].pos = bodys[i - 1].pos;
// 3. 蛇头移动
switch (dir)
{
case E_MoveDir.Up: --bodys[0].pos.y; break;
case E_MoveDir.Down: ++bodys[0].pos.y; break;
case E_MoveDir.Left: bodys[0].pos.x -= 2; break;
case E_MoveDir.Right: bodys[0].pos.x += 2; break;
}
}
关键点:
- 遍历顺序:从尾到头,否则位置会被覆盖
- 残影处理:移动前擦除蛇尾位置
- X坐标移动2:中文字符占2个字符宽度
答题示例
分三步:先擦蛇尾,再让身体跟随,最后移动蛇头。
身体跟随要从尾到头遍历,让每个身体等于前一个的位置。不能从头到尾,不然位置会被覆盖掉。
擦蛇尾是为了避免残影,不然移动后原来的位置还会显示。
X 坐标每次移动 2,因为中文字符占两个字符宽度。
参考文章
- 11.游戏场景-蛇类移动
2. 单一职责原则的理解
题目
什么是单一职责原则?在本项目中如何体现?
深入解析
单一职责原则(SRP):一个类只做一件事,只有一个引起它变化的原因。
本项目的体现:
| 类 | 职责 | 符合SRP |
|---|---|---|
Game |
管理场景切换、主循环 | ✓ |
Map |
管理墙壁数组、绘制地图 | ✓ |
Snake |
管理蛇身体、移动、吃食物 | ✓ |
Food |
管理食物位置、随机生成 | ✓ |
Wall |
管理单个墙壁的位置和绘制 | ✓ |
如果违反SRP,比如把蛇的移动、绘制、碰撞检测都放在一个方法里,代码会变得难以维护。拆分后,每个类只关注自己的职责,修改时不会相互影响。
答题示例
单一职责就是一个类只做一件事。
比如这个项目里,Map 类只管墙壁,Snake 类只管蛇的逻辑,Food 类只管食物。
这样做的好处是改一个功能不会影响其他功能。比如改蛇的移动逻辑,不会影响到地图的绘制。
如果一个类干太多事,代码就会变得很乱,改一处可能到处出问题。
参考文章
- 3.必备知识点-七大原则
进阶题
1. 运算符重载的使用场景
题目
什么是运算符重载?在什么场景下需要使用?本项目是如何使用的?
深入解析
运算符重载:为自定义类型定义运算符的行为,使对象可以像基本类型一样进行运算。
使用场景:
- 自定义数值类型(复数、向量、矩阵)
- 需要比较的自定义类型
- 游戏中的坐标、位置比较
本项目中的使用:
struct Position
{
public int x;
public int y;
public Position(int x, int y)
{
this.x = x;
this.y = y;
}
// 重载 == 运算符
public static bool operator ==(Position p1, Position p2)
{
return p1.x == p2.x && p1.y == p2.y;
}
// 重载 != 运算符(必须成对重载)
public static bool operator !=(Position p1, Position p2)
{
return !(p1 == p2);
}
}
// 使用:直接用 == 比较位置
if (bodys[0].pos == map.walls[i].pos)
{
// 蛇头撞墙
}
重载后,Position 结构体可以直接用 == 和 != 比较,代码更直观。如果不重载,需要写成 p1.x == p2.x && p1.y == p2.y。
注意事项:
==和!=必须成对重载- 重载运算符必须是
public static - 值类型重载
==后,建议同时重写Equals和GetHashCode
答题示例
运算符重载就是让自定义类型也能用加减乘除这些运算符。
比如项目里的 Position 结构体,重载了 == 和 !=,这样两个位置可以直接比较,不用写 x 和 y 分别判断。
游戏开发里经常用到,比如向量类重载加减法,坐标类重载比较运算符。
注意 == 和 != 必须成对重载,而且要声明为 public static。
参考文章
- 7.游戏场景-游戏对象基类、绘制接口和位置结构体
2. 接口在游戏架构中的作用
题目
本项目定义了 ISceneUpdate 和 IDraw 两个接口,它们在架构中起到了什么作用?
深入解析
两个接口的职责:
| 接口 | 方法 | 作用 |
|---|---|---|
ISceneUpdate |
Update() |
帧更新,场景切换的关键 |
IDraw |
Draw() |
绘制,游戏对象的统一绘制接口 |
ISceneUpdate 的作用:
// Game 类中,用接口变量引用当前场景
public static ISceneUpdate nowScene;
// 主循环只关心 Update,不关心具体场景
while (true)
{
if (nowScene != null)
nowScene.Update();
}
// 切换场景时,new 不同的实现类
nowScene = new BeginScene(); // 开始场景
nowScene = new GameScene(); // 游戏场景
nowScene = new EndScene(); // 结束场景
IDraw 的作用:
// 所有可绘制对象实现 IDraw
class Wall : GameObject, IDraw { ... }
class Food : GameObject, IDraw { ... }
class Snake : IDraw { ... }
class Map : IDraw { ... }
// 统一调用 Draw 方法
for (int i = 0; i < walls.Length; i++)
walls[i].Draw();
接口的核心价值:
- 解耦:调用方只依赖接口,不依赖具体实现
- 多态:不同实现类可以统一处理
- 扩展:新增场景或游戏对象,只需实现接口
答题示例
这两个接口起到了解耦和多态的作用。
ISceneUpdate 让 Game 类不用关心具体是哪个场景,只要调用 Update 就行。切换场景就是换个实现类。
IDraw 让所有能绘制的东西都有统一的 Draw 方法,遍历调用时不用管具体类型。
这样设计的好处是扩展方便,加新场景或新游戏对象,实现接口就行,不用改现有代码。
参考文章
- 5.游戏主入口、游戏类和游戏帧更新接口
- 7.游戏场景-游戏对象基类、绘制接口和位置结构体
3. 非阻塞输入检测的实现
题目
在控制台游戏中,如何实现非阻塞的键盘输入检测?为什么要用非阻塞方式?
深入解析
阻塞输入的问题:
// Console.ReadKey() 会阻塞,程序停在这里等输入
char input = Console.ReadKey(true).KeyChar; // 程序卡住等待
如果用阻塞方式,游戏会在每次等待输入时暂停,蛇不会移动,游戏体验极差。
非阻塞输入的实现:
// Console.KeyAvailable 判断是否有按键,不会阻塞
if (Console.KeyAvailable)
{
// 有按键才读取
switch (Console.ReadKey(true).Key)
{
case ConsoleKey.W: snake.ChangeDir(E_MoveDir.Up); break;
case ConsoleKey.S: snake.ChangeDir(E_MoveDir.Down); break;
case ConsoleKey.A: snake.ChangeDir(E_MoveDir.Left); break;
case ConsoleKey.D: snake.ChangeDir(E_MoveDir.Right); break;
}
}
// 没有按键,程序继续执行,蛇继续移动
关键点:
Console.KeyAvailable返回bool,有按键返回true,没有返回false- 不会阻塞程序,无论有没有按键都会立即返回
- 输入检测放在每帧都执行的代码里,而不是放在帧间隔里
public void Update()
{
// 输入检测:每帧都检测,不放在帧间隔里
if (Console.KeyAvailable)
{
// 处理输入...
}
// 帧间隔控制
if (whileLoopTimes % 10000 == 0)
{
// 游戏逻辑...
}
++whileLoopTimes;
}
答题示例
用 Console.KeyAvailable 判断有没有按键,它不会阻塞程序。
如果用 ReadKey,程序会卡住等输入,游戏就停了。
用 KeyAvailable 先判断,有按键才读取,没有就跳过继续执行。
而且输入检测要每帧都做,不能放在帧间隔里,否则会有延迟。
参考文章
- 12.游戏场景-改变蛇类移动方向
深度题
1. 项目制作思路
题目
如果让你从零开始制作这个贪食蛇游戏,你会怎么规划和实现?
深入解析
项目概述:控制台贪食蛇游戏,玩家控制蛇移动、吃食物长身体、撞墙或撞自己结束。
架构设计:
Game(游戏主类)
├── ISceneUpdate(帧更新接口)
├── BeginScene(开始场景)
├── GameScene(游戏场景)
│ ├── Map(地图)
│ │ └── Wall[](墙壁数组)
│ ├── Snake(蛇)
│ │ └── SnakeBody[](身体数组)
│ └── Food(食物)
└── EndScene(结束场景)
开发顺序:
搭建框架
- 创建
Game类,实现主循环 - 定义
ISceneUpdate接口 - 实现场景切换机制
- 创建
实现开始/结束场景
- 抽取
BeginOrEndBaseScene基类 - 实现选项切换和确认逻辑
- 抽取
实现游戏对象系统
Position结构体 + 运算符重载IDraw接口 +GameObject抽象基类Wall、Food、SnakeBody类
实现核心逻辑
Map:四次遍历生成墙壁Snake:移动、方向切换、碰撞检测、吃食物长身体Food:随机位置(不与蛇重合)
整合调试
GameScene组装所有组件- 测试各种边界情况
关键技术点:
| 功能 | 实现方式 |
|---|---|
| 场景切换 | 接口变量 + 静态方法 new 不同场景 |
| 游戏降速 | 帧计数器,每 N 帧更新一次 |
| 非阻塞输入 | Console.KeyAvailable |
| 蛇移动 | 擦除蛇尾 → 身体跟随 → 蛇头移动 |
| 身体跟随 | 从尾到头,后一个 = 前一个位置 |
| 方向限制 | 有身体时不能 180° 转向 |
| 食物随机 | 递归生成直到不与蛇重合 |
难点与解决:
- 蛇移动有残影:移动前擦除蛇尾位置
- 身体跟随:从尾到头遍历,每个身体等于前一个位置
- 180° 转向:判断当前方向和目标方向是否相反
- 食物与蛇重合:递归重新生成位置
答题示例
先搭框架,定义帧更新接口,实现场景切换机制。
然后做游戏对象系统,Position 结构体重载比较运算符,IDraw 接口统一绘制。
核心是蛇的移动逻辑:先擦蛇尾,再从尾到头让每个身体等于前一个位置,最后移动蛇头。
难点主要是身体跟随和防残影,用遍历和擦除解决。
整体用接口解耦,每个类职责单一,方便扩展。
参考文章
- 4.需求分析
- 5.游戏主入口、游戏类和游戏帧更新接口
- 6.多场景切换、开始场景和结束场景
- 7.游戏场景-游戏对象基类、绘制接口和位置结构体
- 8.游戏场景-继承游戏对象基类的类
- 9.游戏场景-地图类
- 10.游戏场景-蛇类
- 11.游戏场景-蛇类移动
- 12.游戏场景-改变蛇类移动方向
- 13.游戏场景-蛇撞墙撞身体结束游戏判定
- 14.游戏场景-蛇吃食物
- 15.游戏场景-蛇长身体
- 16.代码汇总
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com