17.C#核心实践项目总结

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. 蛇移动逻辑的实现

题目

贪食蛇游戏中,蛇的移动逻辑是如何实现的?如何处理身体跟随和残影问题?

深入解析

蛇移动的三个步骤:

  1. 擦除蛇尾:移动前清除最后一个位置,避免残影
  2. 身体跟随:从尾到头遍历,每个身体等于前一个位置
  3. 蛇头移动:根据方向更新蛇头坐标
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
  • 值类型重载 == 后,建议同时重写 EqualsGetHashCode
答题示例

运算符重载就是让自定义类型也能用加减乘除这些运算符。

比如项目里的 Position 结构体,重载了 == 和 !=,这样两个位置可以直接比较,不用写 x 和 y 分别判断。

游戏开发里经常用到,比如向量类重载加减法,坐标类重载比较运算符。

注意 == 和 != 必须成对重载,而且要声明为 public static。

参考文章
  • 7.游戏场景-游戏对象基类、绘制接口和位置结构体

2. 接口在游戏架构中的作用

题目

本项目定义了 ISceneUpdateIDraw 两个接口,它们在架构中起到了什么作用?

深入解析

两个接口的职责:

接口 方法 作用
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(结束场景)

开发顺序

  1. 搭建框架

    • 创建 Game 类,实现主循环
    • 定义 ISceneUpdate 接口
    • 实现场景切换机制
  2. 实现开始/结束场景

    • 抽取 BeginOrEndBaseScene 基类
    • 实现选项切换和确认逻辑
  3. 实现游戏对象系统

    • Position 结构体 + 运算符重载
    • IDraw 接口 + GameObject 抽象基类
    • WallFoodSnakeBody
  4. 实现核心逻辑

    • Map:四次遍历生成墙壁
    • Snake:移动、方向切换、碰撞检测、吃食物长身体
    • Food:随机位置(不与蛇重合)
  5. 整合调试

    • GameScene 组装所有组件
    • 测试各种边界情况

关键技术点

功能 实现方式
场景切换 接口变量 + 静态方法 new 不同场景
游戏降速 帧计数器,每 N 帧更新一次
非阻塞输入 Console.KeyAvailable
蛇移动 擦除蛇尾 → 身体跟随 → 蛇头移动
身体跟随 从尾到头,后一个 = 前一个位置
方向限制 有身体时不能 180° 转向
食物随机 递归生成直到不与蛇重合

难点与解决

  1. 蛇移动有残影:移动前擦除蛇尾位置
  2. 身体跟随:从尾到头遍历,每个身体等于前一个位置
  3. 180° 转向:判断当前方向和目标方向是否相反
  4. 食物与蛇重合:递归重新生成位置
答题示例

先搭框架,定义帧更新接口,实现场景切换机制。

然后做游戏对象系统,Position 结构体重载比较运算符,IDraw 接口统一绘制。

核心是蛇的移动逻辑:先擦蛇尾,再从尾到头让每个身体等于前一个位置,最后移动蛇头。

难点主要是身体跟随和防残影,用遍历和擦除解决。

整体用接口解耦,每个类职责单一,方便扩展。

参考文章
  • 4.需求分析
  • 5.游戏主入口、游戏类和游戏帧更新接口
  • 6.多场景切换、开始场景和结束场景
  • 7.游戏场景-游戏对象基类、绘制接口和位置结构体
  • 8.游戏场景-继承游戏对象基类的类
  • 9.游戏场景-地图类
  • 10.游戏场景-蛇类
  • 11.游戏场景-蛇类移动
  • 12.游戏场景-改变蛇类移动方向
  • 13.游戏场景-蛇撞墙撞身体结束游戏判定
  • 14.游戏场景-蛇吃食物
  • 15.游戏场景-蛇长身体
  • 16.代码汇总


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏