15.C#进阶实践项目总结

15.总结


15.1 知识点

主要学习内容

总结目的


强调


15.2 核心要点速览

基于本系列 14 篇正文完整阅读提取,按逻辑分类。

项目概述

项目 说明
目标 控制台俄罗斯方块:场景切换沿用贪吃蛇框架,核心为方块生成、移动、变形、落底、消行、结束判定
核心类 游戏对象类(小方块)、地图类(固定墙+动态墙)、砖块信息类(形态偏移)、搬砖工人类(方块与地图交互)
坐标 一个方块 = 4 个 DrawObject;blocks[0] 为原点,其余位置 = 原点 + BlockInfo 中的相对偏移;横向步长 2,纵向 1

场景与入口

类型 说明
Game w=50,h=35nowScene 当前场景;Start() 死循环调 nowScene.Update()ChangeScene 先 Clear 再 new 对应场景
ISceneUpdate 接口,仅 void Update()
Begin/End 继承 BeginOrEndBaseScene;W/S 选选项、J 确认;Begin 选 0 进游戏/1 退出,End 选 0 回开始/1 退出

绘制与基础类型

类型 说明
IDraw void Draw()
Position 结构体 x,y;重载 ==!=+
DrawObject pos+E_DrawType;Draw 按类型设颜色写「■」;ClearDraw 写两空格;ChangeType 落底时改为墙
E_DrawType Wall/Cube/Line/Tank/Left_Ladder/Right_Ladder/Left_Long_Ladder/Right_Long_Ladder(共 8 种,随机时用 1-7 排除墙)

地图

能力 说明
固定墙 构造里:底边 i+=2 到 Game.w,两侧 0Game.w-2,高度 Game.h-6;存于 walls
动态墙 dynamicWalls;AddWalls(list):遍历改类型为 Wall 并加入,消行逻辑里顺带 ClearDraw、CheckClear、再 Draw
消行 recordInfo[行] 记录每行方块数;满行则该行方块进 delList 再 Remove、上行 y+1、recordInfo 下移、递归 CheckClear;行索引 h-1-pos.y
结束 Map(GameScene);AddWalls 时若某块 pos.y<=0 则 StopThread、ChangeScene(End)、return

方块形态与搬砖工人

能力 说明
BlockInfo 按 E_DrawType 存 List<Position[]>,每元素为一种形态的 3 个相对偏移;索引器越界时回退到首/尾;Count 为形态数
BlockWorker blocks 当前 4 块,blockInfoDic 七种类型对应 BlockInfo,nowBlockInfo+nowInfoIndex 当前形态
随机出新块 (E_DrawType)r.Next(1,8);4 个 DrawObject(type);原点 blocks[0].pos=(24,5) 或 (24,-5);取形态偏移算 blocks[1..3].pos
变形 E_Change_Type Left/Right;Change 前 ClearDraw,改 nowInfoIndex(循环),用 nowBlockInfo[nowInfoIndex] 重算三块 pos 再 Draw
可变形判断 CanChange:用临时索引取形态,算 4 点;若任一点 x<2、x>=Game.w-2、y>=map.h 或与 dynamicWalls 某点同则 false

移动与落底

能力 说明
左右 MoveRL:ClearDraw,偏移 (±2,0),遍历 pos+= 再 Draw;CanMoveRL:模拟偏移后查边界与 dynamicWalls
下落 AutoMove:ClearDraw,各块 pos.y+=1 再 Draw;CanMove:模拟 (0,1),触底或碰动态墙则 AddWalls(blocks)、RandomCreateBlock()、return false
主循环 Update 内:lock(blockWorker){ map.Draw(); blockWorker.Draw(); if(CanMove) AutoMove(); } 然后 Thread.Sleep(200)

输入与线程

能力 说明
问题 Update 里 Sleep 导致输入延迟;双线程同时写控制台需同步
做法 单独输入线程 while(true) 里 KeyAvailable 时 lock(blockWorker) 读键并执行变形/左右/下落;锁内不含 Sleep
优化 InputThread 单例,内部线程死循环调 inputEvent?.Invoke();GameScene 构造时 inputEvent += CheckInputThread,StopThread 时 -= CheckInputThread;场景 new 时不再 new 线程,避免线程堆积

实现顺序建议

  1. 场景框架(Program、Game、ISceneUpdate、Begin/End/GameScene 空壳)。
  2. 绘制基础(IDraw、Position、E_DrawType、DrawObject、ClearDraw、ChangeType)。
  3. 地图(Map 固定墙+动态墙、Draw、AddWalls)。
  4. 形态数据(BlockInfo 各类型多形态偏移、索引器、Count)。
  5. 搬砖工人(blockInfoDic、RandomCreateBlock、Draw)。
  6. 变形(E_Change_Type、nowInfoIndex、Change、ClearDraw、CanChange)。
  7. 左右移动(MoveRL、CanMoveRL)。
  8. 下落与落底(AutoMove、CanMove、AddWalls+RandomCreateBlock)。
  9. 输入线程(Thread、lock(blockWorker)、键位映射)。
  10. 消行(recordInfo、AddWalls 里计数、CheckClear 递归)。
  11. 结束(y<=0 判定、StopThread、Map 持 GameScene)。
  12. 可选:InputThread 单例+事件、新块初始 y 上移、屏幕外不绘制。

15.3 面试题精选

基础题

1. 多线程下为什么用 lock,锁内为什么不能放 Sleep?

题目

这个项目里输入和主循环是两个线程,为什么要用 lock?为什么说「锁里面不要包含休眠」?

深入解析

主线程在 Update 里做绘制和自动下落,并 Thread.Sleep(200);输入线程在 CheckInputThread 里读键并改方块。两边都会调用 Console 设光标、写字符,以及改 BlockWorkerblocks 位置。若不加锁,两线程交错执行会导致光标乱跳、方块位置被并发写,出现「方块乱飞」。所以用 lock(blockWorker) 把「读键 + 执行变形/左右/下落」和「绘制 + 自动下落」包成互斥块,保证同一时刻只有一方在改状态和写控制台。

锁内不能放 Sleep 的原因:锁是互斥的,谁拿到谁执行,执行完才释放。若在锁内 Thread.Sleep(200),这 200ms 内锁一直不释放,另一个线程就卡在 lock(blockWorker) 外干等,输入或主循环会明显卡顿。所以 Sleep 要放在 lock 外面,只让当前线程睡眠,不占用锁。

答题示例

因为主线程负责绘制和自动下落,输入线程负责读键盘并改方块位置,两边都会写控制台和改同一份 blocks,如果不加锁就会并发写,出现光标乱跳、方块乱飞。所以用 lock(blockWorker) 把两边的逻辑包起来,保证同一时刻只有一个线程在改状态和写控制台。
锁里面不能 Sleep 是因为锁是互斥的,在锁里睡 200ms 的话,另一个线程会一直拿不到锁,输入或主循环就卡住了。所以 Sleep 要放在 lock 外面,只让当前线程睡,不占着锁。

参考文章
  • 10.输入线程

2. 俄罗斯方块里「可移动/可变形」判断为什么要先模拟再判断,而不是先改再还原?

题目

左右移动、下落、变形前都会先调 CanMoveRL / CanMove / CanChange 判断,这些方法里是用临时变量模拟一步再检查边界和碰撞。为什么不能先真的改位置,发现不行再改回去?

深入解析

先改再还原在单线程下理论上可行,但会多一次写状态和一次还原,代码也容易漏还原。更关键的是,项目里输入线程和主线程都会改 blocks:若输入线程「先改位置发现不行再还原」,在「改」和「还原」之间主线程可能正好在做绘制或自动下落,读到中间状态,画面或逻辑就会错乱。用「临时变量模拟一步,只读不写」则不会改真实状态,任何线程读到的都是已提交的状态,避免竞态。另外,模拟一次只做数学运算和碰撞检测,逻辑清晰,也方便复用(例如 CanChange 用临时索引取形态算四个点,再查边界和 dynamicWalls)。

答题示例

主要是为了不破坏当前状态,避免多线程读到中间状态。如果先改位置再判断、不行再改回去,在「改」和「改回去」之间,主线程可能正在绘制或做自动下落,就会读到一半被改过的 blocks,容易乱。用临时变量模拟一步,只做碰撞和边界判断,不写回 blocks,这样任何时候别的线程读到的都是完整的一帧状态,不会出现竞态。而且先模拟再判断逻辑更清晰,代码也好维护。

参考文章
  • 7.方块变形
  • 8.方块左右移动
  • 9.方块自动向下移动
  • 10.输入线程

进阶题

1. 项目整体制作思路与模块划分

题目

如果从零做这个控制台俄罗斯方块,你会怎么规划?先做什么后做什么?各模块职责是什么?

深入解析

目标:控制台俄罗斯方块,场景切换沿用贪吃蛇那套(Game + ISceneUpdate + Begin/End/GameScene)。核心玩法是方块生成、左右移动、变形、自动下落、落底后加入地图、消行、顶满结束。

模块可以按「场景与入口」「绘制与基础数据」「地图」「方块形态与当前块」「输入与主循环」来划。先做场景框架(Program、Game、ISceneUpdate、Begin/End/空 GameScene),能切场景再往下做。然后做绘制基础:IDraw、Position 结构体(含 ==、!=、+ 重载)、E_DrawType 枚举、DrawObject(Draw/ClearDraw/ChangeType),这样单格能画能擦能改类型。再做 Map:固定墙在构造里按宽高生成,动态墙用 List,提供 Draw 和 AddWalls,这样落底的方块可以转成墙。接着做方块形态数据 BlockInfo:按类型存多种形态的相对偏移 List<Position[]>,索引器越界时回退到首/尾,用于算四个格子的世界坐标。然后做 BlockWorker:blockInfoDic 存七种 BlockInfo,RandomCreateBlock 随机类型(排除墙)、随机形态、设原点与其余三块位置,Draw 遍历 blocks 绘制。再在 BlockWorker 里加变形:ClearDraw、改 nowInfoIndex(循环)、用当前形态偏移重算三块 pos、Draw;CanChange 用临时索引模拟变形后的四点,只做边界和与 dynamicWalls 的重合判断。同理加左右移动(MoveRL/CanMoveRL)和下落(AutoMove/CanMove,不能动时 AddWalls+RandomCreateBlock)。主循环在 GameScene.Update 里:lock 内绘制、Draw、若 CanMove 则 AutoMove,lock 外 Sleep。再开输入线程,在 lock(blockWorker) 里读键并调变形/左右/下落。地图侧:AddWalls 时对每个块按 pos.y 更新 recordInfo 行计数,满行则 CheckClear(删行、上行下移、recordInfo 下移、递归)。最后结束判定:Map 持 GameScene,AddWalls 时若某块 pos.y<=0 则 StopThread、ChangeScene(End)、return。可选优化:InputThread 单例 + 事件,避免每次 new GameScene 都 new 线程;新块初始 y 上移避免一出生就顶满;屏幕外不绘制不擦除。

答题示例

先做场景框架,能切开始/游戏/结束场景,再往里填游戏逻辑。然后做绘制基础:位置结构体、绘制接口、按类型画格子和擦除,这样地图和方块都能复用。地图分固定墙和动态墙,落底的方块转成墙加入动态墙列表。
方块形态用 BlockInfo 存每种类型多种形态的相对偏移,一个当前块用四个 DrawObject,blocks[0] 是原点,其余三个用原点加偏移算出来。搬砖工人用 Dictionary 存七种 BlockInfo,随机出块时随机类型和形态,再算四个格子的坐标。
移动和变形前都先「模拟一步」判断能不能动,再真正改位置,避免多线程读到中间状态。主循环里 lock 住再绘制和自动下落,锁外 Sleep;输入单独一个线程,读键时同样 lock 再操作方块。消行用每行计数数组,满行就删那一行、上面的下移、递归检查。结束判定在 AddWalls 里看有没有块顶到 y<=0,有就停输入线程并切结束场景。

参考文章
  • 1.需求分析
  • 2.复用修改贪食蛇相关代码
  • 3.绘制对象基类和类型枚举
  • 4.地图固定墙壁和动态墙壁相关
  • 5.方块变形信息类
  • 6.搬砖工人类随机创建砖块
  • 7.方块变形
  • 8.方块左右移动
  • 9.方块自动向下移动
  • 10.输入线程
  • 11.消除方块
  • 12.结束流程
  • 13.优化输入线程

2. 输入线程为什么要改成单例 + 事件,和「每个 GameScene 自己 new 线程」有什么区别?

题目

为什么后来要做一个 InputThread 单例,用事件来触发输入检测?和每个游戏场景里自己 new 一个输入线程相比,有什么问题?

深入解析

每次进入游戏都会 new GameScene(),若在 GameScene 里 new Thread(CheckInputThread).Start(),每次切回开始再进游戏就会再 new 一个线程。旧场景被换掉后,旧线程的 while(true) 还在跑,只是 nowScene 已经指向新场景,但旧线程可能还在读键、还在对旧的 blockWorker 加锁或操作,导致线程堆积、逻辑错乱。单例 + 事件的做法是:只保留一个 InputThread 实例,内部只有一个线程死循环调 inputEvent?.Invoke()。GameScene 构造时把 CheckInputThread 挂到 inputEvent 上,StopThread 时从 inputEvent 上摘掉。这样切场景时只是订阅/退订,不会 new 新线程,不会堆积;结束场景时退订后,事件里没有方法,Invoke 相当于空转,不会再去操作已销毁的场景。

答题示例

每个 GameScene 自己 new 线程的话,每次重新进游戏都会多一个输入线程,旧场景被换掉了但旧线程的 while(true) 还在跑,线程会越积越多,而且可能还在读键、操作已经不该再用的对象。改成单例加事件之后,全局就一个输入线程,只负责循环调 inputEvent;进游戏时把检测输入的方法挂上去,结束场景时摘掉。这样切场景不会 new 新线程,不会堆积,退订之后事件为空,也不会再动到已销毁的场景。

参考文章
  • 10.输入线程
  • 12.结束流程
  • 13.优化输入线程

3. 消行为什么要递归调用 CheckClear,用循环不行吗?

题目

消行逻辑里,消掉一行后会递归再调一次 CheckClear,为什么不能只循环一遍 recordInfo?

深入解析

消掉一行后,上面的行会整体下移一格(dynamicWalls[j].pos.y++),recordInfo 也会整体下移(recordInfo[j] = recordInfo[j+1]),原来「上一行」的数据到了当前下标。若只遍历一次,第一次消的是第 i 行,改完数组和 pos 后,下一次循环的 i+1 对应的已经是「原来的上一行」了,但这一行可能现在也满了(因为上面又掉下来一行补上了)。一次遍历只能保证消掉「当前」的满行,消完后新露出来的满行要再扫一遍才能消。递归的作用就是:消完一行后立刻从头再检查一遍 recordInfo,看有没有新的满行,有就再消、再递归,直到这一轮没有满行为止。用循环也可以,但要外层「只要本轮消过行就再扫一遍」的 while,和递归等价,递归写法更直接。

答题示例

消掉一行之后,上面的方块会下移,recordInfo 数组也会整体上移一行。如果只遍历一次,下一次循环看到的是新的下标对应的行,可能因为上面补下来也满了,但这次遍历不会再去消它。所以消完一行后要再从头检查一遍有没有新的满行,递归就是「消一行后再调一次 CheckClear」,等价于「只要消过就再扫一遍」,直到没有满行为止。用 while 循环也可以,逻辑一样。

参考文章
  • 11.消除方块

深度题

1. 结构体 Position 重载 == 和 + 在碰撞与移动里是怎么用的?为什么用结构体而不是类?

题目

Position 是结构体,重载了 == 和 +,在项目里哪些地方用到了?用 struct 而不是 class 有什么考虑?

深入解析

+ 用于「原点 + 相对偏移」得到世界坐标:RandomCreateBlock 和 Change 里 blocks[i+1].pos = blocks[0].pos + pos[i];MoveRL 里 Position movePos = new Position(±2, 0),然后 blocks[i].pos += movePos(会调 + 再赋值)。== 用于碰撞:CanChange、CanMoveRL、CanMove 里判断「模拟后的位置是否和 map.dynamicWalls[j].pos 相等」。用结构体是因为位置是值类型,拷贝小、无堆分配,做临时变量和参数传递时不会产生多余引用;比较时按值比较,两个 Position 的 x、y 都相等才相等,符合「坐标相同即重合」的语义。若用 class,默认 == 比较引用,要重写 Equals 和 == 才能按值比较,且传参、赋值会拷贝引用,容易在「改临时量」时误以为不影响原对象(本项目里大量用临时 Position 做模拟判断,用 struct 更安全)。

答题示例

加号用在「原点加偏移」算四个格子的世界坐标,比如 blocks[0].pos + 形态偏移;左右移动时也是加一个 (±2,0) 的偏移。等号用在碰撞判断,模拟移动或变形后的位置和 dynamicWalls 里每个块的 pos 比,相等就说明重合了。
用结构体是因为坐标是值类型,拷贝开销小,也没有堆分配。做模拟判断时用临时 Position,改临时量不会影响到原来的 blocks。而且两个位置「相等」我们关心的是 x、y 是否都相同,用 struct 重载 == 按值比较就行;用 class 的话默认比引用,还要自己重写 Equals 和 ==。

参考文章
  • 3.绘制对象基类和类型枚举
  • 7.方块变形
  • 8.方块左右移动
  • 9.方块自动向下移动
  • 11.消除方块


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

×

喜欢就点赞,疼爱就打赏