C#实现汉诺塔游戏:从递归算法到WinForms桌面应用开发

📅 2026/8/3 6:22:40
C#实现汉诺塔游戏:从递归算法到WinForms桌面应用开发
1. 项目概述从经典算法到完整应用汉诺塔这个在计算机科学和算法入门课上绕不开的经典问题相信每个学过递归的同学都曾被它“折磨”过。它通常以一道纯粹的算法题出现要求你写出递归函数计算移动步骤。但今天我们不只满足于在控制台打印“A-C”这样的字符串。我想和你分享的是如何用C#将这道经典的递归算法题变成一个拥有图形界面、可交互、能记录步数、甚至带点动画效果的桌面小游戏。这不仅仅是完成一个作业更是将算法、面向对象设计、事件驱动编程和用户界面UI开发串联起来的一次绝佳实践。为什么选择C#和Windows窗体WinForms来做这件事首先C#的语法清晰面向对象特性完善非常适合用来构建结构良好的应用程序。WinForms虽然被很多人认为是“老技术”但对于快速构建桌面GUI应用、理解事件驱动模型来说它直观、简单无需复杂的配置能让我们把精力集中在游戏逻辑和用户体验上。这个项目非常适合C#初学者在掌握了基础语法和递归概念后用来巩固面向对象思想并初次涉足GUI编程。对于有经验的开发者你也可以在其中实践设计模式比如用观察者模式来更新UI状态或者用状态模式来管理游戏的不同阶段。最终你将得到一个.exe文件双击即可运行。界面上会有三根柱子A, B, C若干数量可自定义的圆盘以及开始、重置、步数显示等功能。用户可以通过拖拽或点击来移动圆盘程序会自动判断移动是否合法并记录最优解步数和用户实际步数。下面我们就来一步步拆解这个项目的实现。2. 核心数据结构与算法设计在动手画界面之前我们必须先把游戏的核心逻辑也就是“大脑”部分设计好。这部分是纯算法的不依赖任何UI这符合良好的分层设计思想业务逻辑与表现层分离。2.1 圆盘与柱子的数据模型首先我们需要抽象出两个核心类Disk圆盘和Peg柱子。Disk类很简单它主要关心自己的大小宽度。在后续绘制时大小决定了它的视觉宽度。我们也可以给它加个颜色属性让不同大小的圆盘颜色不同增加美观度。public class Disk { public int Size { get; } // 圆盘大小值越小圆盘越小在最上面 public Color Color { get; set; } // 圆盘颜色 public Disk(int size) { Size size; // 可以基于size生成一个渐变色 Color Color.FromArgb(200, 50 size * 20, 100, 150); } }Peg类是关键。它代表一根柱子核心功能是管理一堆按顺序叠放的Disk。这里最适合的数据结构是StackDisk。栈Stack的“后进先出”LIFO特性完美模拟了汉诺塔的规则你只能移动最顶端的圆盘也只能将圆盘放在空柱子或比它大的圆盘上。public class Peg { public string Name { get; } // 柱子名称如 A, B, C private StackDisk _disks; // 存储圆盘的栈 public Peg(string name) { Name name; _disks new StackDisk(); } // 尝试将圆盘压入栈顶放入柱子 public bool TryPush(Disk disk) { if (IsEmpty || disk.Size Peek().Size) { _disks.Push(disk); return true; } return false; // 放置非法目标柱顶圆盘比待放入的小 } // 从栈顶弹出圆盘从柱子顶端取走 public Disk Pop() { return _disks.Pop(); } // 查看栈顶圆盘而不弹出 public Disk Peek() { return _disks.Peek(); } public bool IsEmpty _disks.Count 0; public int DiskCount _disks.Count; public IEnumerableDisk Disks _disks.Reverse(); // 注意遍历时从底到顶更符合绘制逻辑 }注意TryPush方法是规则的核心。它检查柱子是否为空或者待放入的圆盘是否小于柱子顶部的圆盘。Disks属性返回_disks.Reverse()是因为栈的枚举顺序是从顶到底而我们从底到顶绘制更直观。2.2 递归算法与最优解计算汉诺塔的递归解法是其精髓。算法本身不复杂但理解其递归思想至关重要。我们将其封装在游戏逻辑类HanoiGame中。public class HanoiGame { private Peg _pegA, _pegB, _pegC; public int TotalDisks { get; private set; } public int MinimumMoves { get; private set; } // 最优解步数 2^n - 1 public int UserMoves { get; private set; } // 用户已走步数 public HanoiGame(int diskCount) { TotalDisks diskCount; MinimumMoves (int)Math.Pow(2, diskCount) - 1; // 经典公式 UserMoves 0; _pegA new Peg(A); _pegB new Peg(B); _pegC new Peg(C); // 初始化将所有圆盘按从大到小的顺序放入A柱 for (int i diskCount; i 0; i--) { _pegA.TryPush(new Disk(i)); } } // 核心递归算法将n个盘从source柱借助auxiliary柱移动到target柱 private void SolveRecursively(int n, Peg source, Peg auxiliary, Peg target, List(Peg, Peg) moveSequence) { if (n 1) { // 递归基只有一个盘子直接移动 moveSequence.Add((source, target)); } else { // 第一步将上面n-1个盘子从source移到auxiliary借助target SolveRecursively(n - 1, source, target, auxiliary, moveSequence); // 第二步将最大的第n个盘子从source移到target moveSequence.Add((source, target)); // 第三步将auxiliary上的n-1个盘子移到target借助source SolveRecursively(n - 1, auxiliary, source, target, moveSequence); } } // 获取最优解的移动步骤列表可用于演示或提示 public List(Peg From, Peg To) GetOptimalSolution() { var solution new List(Peg, Peg)(); SolveRecursively(TotalDisks, _pegA, _pegB, _pegC, solution); return solution; } }实操心得递归函数SolveRecursively的参数设计很重要。除了盘子数量n必须明确指定三根柱子在当前递归上下文中的角色源、辅助、目标。这个算法生成的是最优解步骤步数一定是2^n - 1。我们可以把这个步数显示在UI上让用户知道自己离“天才”还有多远。2.3 游戏状态管理与用户交互逻辑HanoiGame类还需要处理用户的移动操作和游戏状态判断。public class HanoiGame { // ... 接上文属性与构造函数 ... // 获取三根柱子的引用供UI绑定 public Peg PegA _pegA; public Peg PegB _pegB; public Peg PegC _pegC; // 用户尝试移动从fromPeg移动一个圆盘到toPeg public bool TryMoveDisk(Peg fromPeg, Peg toPeg) { if (fromPeg.IsEmpty) return false; // 源柱为空无法移动 Disk diskToMove fromPeg.Peek(); // 只看不拿 if (toPeg.TryPush(diskToMove)) // 尝试放入目标柱 { // 放入成功才真正从源柱取出 fromPeg.Pop(); UserMoves; return true; } return false; // 移动非法 } // 判断游戏是否胜利所有圆盘都按顺序移动到C柱 public bool CheckWin() { return _pegC.DiskCount TotalDisks; } // 重置游戏 public void Reset() { UserMoves 0; // 清空所有柱子 while (!_pegA.IsEmpty) _pegA.Pop(); while (!_pegB.IsEmpty) _pegB.Pop(); while (!_pegC.IsEmpty) _pegC.Pop(); // 重新初始化A柱 for (int i TotalDisks; i 0; i--) { _pegA.TryPush(new Disk(i)); } } }这里有一个关键点TryMoveDisk方法实现了“原子性”操作。它先“窥视”源柱顶端的圆盘尝试将其“虚拟”放入目标柱。只有目标柱允许放入时才真正执行弹出和压入操作并增加步数。这保证了游戏状态在任何时候都是合法且一致的。3. WinForms界面设计与绘制有了强大的逻辑核心现在我们来为它打造一个“外表”。使用WinForms我们可以通过代码控制每一个像素的绘制实现高度自定义的界面。3.1 主窗体与控件布局我们创建一个MainForm并在上面放置必要的控件。布局上游戏绘制区域Panel占据主要空间控制面板如按钮、标签放在下方或侧边。public partial class MainForm : Form { private HanoiGame _game; private int _diskCount 5; // 默认圆盘数 private const int PegWidth 20; private const int BaseHeight 30; private const int DiskHeight 25; private Color _pegColor Color.SaddleBrown; private Color _baseColor Color.Peru; private Panel _gamePanel; // 用于绘制游戏区域的画布 private Label _lblMoves; private Label _lblMinMoves; private Button _btnReset; private NumericUpDown _numDiskCount; private Button _btnAutoSolve; public MainForm() { InitializeComponent(); InitializeGame(); SetupUI(); } private void InitializeGame() { _game new HanoiGame(_diskCount); } private void SetupUI() { this.Text C# 汉诺塔游戏; this.Size new Size(900, 700); // 游戏绘制面板 _gamePanel new Panel { Location new Point(10, 10), Size new Size(this.ClientSize.Width - 20, 500), BackColor Color.LightGray, BorderStyle BorderStyle.FixedSingle }; _gamePanel.Paint GamePanel_Paint; // 绑定绘制事件 _gamePanel.MouseDown GamePanel_MouseDown; // 鼠标点击选择圆盘 _gamePanel.MouseUp GamePanel_MouseUp; // 鼠标释放放置圆盘 this.Controls.Add(_gamePanel); // 控制面板 var controlPanel new FlowLayoutPanel { FlowDirection FlowDirection.LeftToRight, Dock DockStyle.Bottom, Height 80 }; _lblMinMoves new Label { Text $最优解步数: {_game.MinimumMoves}, AutoSize true, Font new Font(微软雅黑, 10) }; _lblMoves new Label { Text $你的步数: {_game.UserMoves}, AutoSize true, Font new Font(微软雅黑, 10, FontStyle.Bold) }; _numDiskCount new NumericUpDown { Minimum 3, Maximum 10, Value _diskCount, Width 60 }; _numDiskCount.ValueChanged NumDiskCount_ValueChanged; // 圆盘数量改变事件 _btnReset new Button { Text 重置游戏, Size new Size(80, 30) }; _btnReset.Click BtnReset_Click; _btnAutoSolve new Button { Text 自动演示, Size new Size(80, 30) }; _btnAutoSolve.Click BtnAutoSolve_Click; controlPanel.Controls.AddRange(new Control[] { _lblMinMoves, _lblMoves, new Label { Text 圆盘数量: }, _numDiskCount, _btnReset, _btnAutoSolve }); this.Controls.Add(controlPanel); } }3.2 自定义绘制游戏元素WinForms绘制的核心是Graphics对象。我们在GamePanel_Paint事件处理程序中绘制三根柱子、底座和所有圆盘。private void GamePanel_Paint(object sender, PaintEventArgs e) { Graphics g e.Graphics; g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; // 开启抗锯齿让图形更平滑 int panelWidth _gamePanel.Width; int panelHeight _gamePanel.Height; int pegSpacing panelWidth / 4; // 柱子间距 int baseY panelHeight - BaseHeight - 50; // 底座Y坐标 int pegTop baseY - 300; // 柱子顶部Y坐标 // 1. 绘制底座 g.FillRectangle(new SolidBrush(_baseColor), 50, baseY, panelWidth - 100, BaseHeight); // 2. 绘制三根柱子 for (int i 0; i 3; i) { int pegX pegSpacing * (i 1); // 计算每根柱子的X坐标 Peg currentPeg i 0 ? _game.PegA : (i 1 ? _game.PegB : _game.PegC); // 绘制柱身 g.FillRectangle(new SolidBrush(_pegColor), pegX - PegWidth / 2, pegTop, PegWidth, baseY - pegTop); // 绘制柱子标签A, B, C g.DrawString(currentPeg.Name, this.Font, Brushes.Black, pegX - 5, baseY 10); // 3. 绘制该柱子上的所有圆盘 int diskBottom baseY; // 当前圆盘绘制的底部Y坐标从底座开始往上画 // 注意遍历Disks属性已Reverse是从底到顶 foreach (var disk in currentPeg.Disks) { int diskWidth 20 disk.Size * 20; // 圆盘宽度根据Size线性增加 int diskLeft pegX - diskWidth / 2; int diskTop diskBottom - DiskHeight; // 绘制圆盘带圆角效果 Rectangle diskRect new Rectangle(diskLeft, diskTop, diskWidth, DiskHeight); using (var path GetRoundedRectPath(diskRect, 5)) { g.FillPath(new SolidBrush(disk.Color), path); g.DrawPath(Pens.DarkSlateGray, path); // 绘制边框 } diskBottom diskTop; // 下一个圆盘画在当前圆盘上方 } } } // 辅助方法创建圆角矩形路径 private System.Drawing.Drawing2D.GraphicsPath GetRoundedRectPath(Rectangle rect, int radius) { var path new System.Drawing.Drawing2D.GraphicsPath(); path.AddArc(rect.X, rect.Y, radius * 2, radius * 2, 180, 90); path.AddArc(rect.Right - radius * 2, rect.Y, radius * 2, radius * 2, 270, 90); path.AddArc(rect.Right - radius * 2, rect.Bottom - radius * 2, radius * 2, radius * 2, 0, 90); path.AddArc(rect.X, rect.Bottom - radius * 2, radius * 2, radius * 2, 90, 90); path.CloseFigure(); return path; }注意事项绘制圆盘的顺序至关重要。必须从下往上画即从柱子底部第一个圆盘画到顶部最后一个这样才能保证后画的上面的圆盘覆盖先画的下面的圆盘视觉效果正确。这就是为什么Peg.Disks属性要返回Reverse()的原因。GetRoundedRectPath方法让圆盘看起来更美观不是生硬的矩形。3.3 实现鼠标交互与拖拽逻辑为了让游戏可玩我们需要实现鼠标点击选择圆盘拖拽或再次点击放置圆盘的逻辑。这里采用一个简单的“选择-放置”两段式交互比实时拖拽更易实现且稳定。private Peg _selectedPeg null; // 记录当前选中的柱子源柱 private void GamePanel_MouseDown(object sender, MouseEventArgs e) { // 1. 根据点击的X坐标判断点击了哪根柱子 int pegSpacing _gamePanel.Width / 4; int pegIndex -1; for (int i 0; i 3; i) { int pegCenterX pegSpacing * (i 1); if (Math.Abs(e.X - pegCenterX) 50) // 点击范围在柱子中心左右50像素内 { pegIndex i; break; } } if (pegIndex -1) return; Peg clickedPeg pegIndex 0 ? _game.PegA : (pegIndex 1 ? _game.PegB : _game.PegC); // 2. 如果之前没有选中柱子且点击的柱子有圆盘则选中它作为源柱 if (_selectedPeg null !clickedPeg.IsEmpty) { _selectedPeg clickedPeg; _gamePanel.Invalidate(); // 重绘可以高亮选中的柱子 return; } // 3. 如果之前已经选中了一个柱子源柱现在点击另一个柱子目标柱则尝试移动 if (_selectedPeg ! null _selectedPeg ! clickedPeg) { bool moveSuccess _game.TryMoveDisk(_selectedPeg, clickedPeg); if (moveSuccess) { UpdateMoveCountDisplay(); _gamePanel.Invalidate(); // 移动成功重绘界面 // 检查是否获胜 if (_game.CheckWin()) { MessageBox.Show($恭喜你你用了 {_game.UserMoves} 步完成了游戏。最优解是 {_game.MinimumMoves} 步。, 游戏胜利); } } else { MessageBox.Show(移动非法只能将圆盘移到空柱子或更大的圆盘上。, 提示); } // 无论成功与否都清除选中状态 _selectedPeg null; _gamePanel.Invalidate(); } else if (_selectedPeg ! null _selectedPeg clickedPeg) { // 点击了同一个柱子取消选中 _selectedPeg null; _gamePanel.Invalidate(); } } private void GamePanel_MouseUp(object sender, MouseEventArgs e) { // 对于简单的点击交互MouseUp事件可以不做处理逻辑已在MouseDown中完成。 // 如果需要实现真正的拖拽按住移动鼠标再释放则需要在这里处理放置逻辑并记录鼠标按下和释放时的坐标。 } private void UpdateMoveCountDisplay() { _lblMoves.Text $你的步数: {_game.UserMoves}; // 可以添加颜色提示如果步数远超最优解标红 if (_game.UserMoves _game.MinimumMoves * 1.5) { _lblMoves.ForeColor Color.Red; } else if (_game.UserMoves _game.MinimumMoves) { _lblMoves.ForeColor Color.Orange; } else { _lblMoves.ForeColor Color.Green; } }实操心得交互逻辑是UI部分最容易出bug的地方。要仔细处理各种边界情况点击空白处、点击空柱子、重复点击同一柱子等。_gamePanel.Invalidate()方法是触发界面重绘的关键任何改变游戏状态的操作后都必须调用它。在MouseDown中判断点击了哪根柱子是通过计算点击位置与三根柱子中心位置的相对距离来实现的这是一种简单有效的命中测试方法。4. 功能扩展与高级特性实现一个基础的游戏已经完成了。但我们可以让它更友好、更强大。下面添加几个提升用户体验的功能。4.1 游戏重置与难度选择允许用户动态改变圆盘数量即改变游戏难度。这需要重置整个游戏状态。private void NumDiskCount_ValueChanged(object sender, EventArgs e) { _diskCount (int)_numDiskCount.Value; ResetGame(); } private void BtnReset_Click(object sender, EventArgs e) { ResetGame(); } private void ResetGame() { _game new HanoiGame(_diskCount); _selectedPeg null; _lblMinMoves.Text $最优解步数: {_game.MinimumMoves}; UpdateMoveCountDisplay(); _gamePanel.Invalidate(); // 重绘界面 }4.2 自动求解与动画演示这是项目的亮点之一。我们可以利用GetOptimalSolution()方法获得最优解步骤然后用定时器Timer控制每一步的移动形成动画演示。private System.Windows.Forms.Timer _animationTimer; private List(Peg From, Peg To) _solutionSteps; private int _currentStepIndex; private void BtnAutoSolve_Click(object sender, EventArgs e) { _solutionSteps _game.GetOptimalSolution(); _currentStepIndex 0; _btnAutoSolve.Enabled false; _btnReset.Enabled false; _numDiskCount.Enabled false; if (_animationTimer null) { _animationTimer new System.Windows.Forms.Timer { Interval 500 }; // 每500毫秒一步 _animationTimer.Tick AnimationTimer_Tick; } _animationTimer.Start(); } private void AnimationTimer_Tick(object sender, EventArgs e) { if (_currentStepIndex _solutionSteps.Count) { _animationTimer.Stop(); _btnAutoSolve.Enabled true; _btnReset.Enabled true; _numDiskCount.Enabled true; MessageBox.Show(自动演示完成, 提示); return; } var step _solutionSteps[_currentStepIndex]; // 注意这里直接调用游戏逻辑的移动因为步骤本身就是合法的 _game.TryMoveDisk(step.From, step.To); // 这里需要为TryMoveDisk添加一个重载或修改使其在自动演示时不增加UserMoves可以加个参数控制。 // 更好的做法为演示单独设计一个不记录用户步数的移动方法或者演示前备份状态演示后恢复。 _currentStepIndex; UpdateMoveCountDisplay(); _gamePanel.Invalidate(); }注意事项自动演示功能需要小心处理。直接调用TryMoveDisk会增加用户步数这显然不合理。有两种解决方案1) 在HanoiGame类中增加一个MoveDiskForAnimation方法它只移动圆盘不增加UserMoves。2) 在开始演示前保存当前游戏状态和步数演示结束后再恢复。我倾向于第一种因为它职责更清晰。4.3 音效与状态反馈为了增强体验可以添加简单的音效。例如移动成功时播放一个短促的音调移动失败或游戏胜利时播放不同的声音。可以使用System.Media.SoundPlayer播放.wav文件。using System.Media; private void PlaySound(string soundName) { try { // 假设声音文件放在项目的 Resources 文件夹中并已嵌入资源 var stream Properties.Resources.ResourceManager.GetStream(soundName); if (stream ! null) { using (var player new SoundPlayer(stream)) { player.Play(); // Play是异步的不会阻塞UI } } } catch { /* 忽略声音播放错误 */ } } // 在 TryMoveDisk 成功或失败以及游戏胜利时调用 PlaySound同时在界面上可以提供更丰富的反馈。例如当鼠标悬停在可移动的圆盘上时圆盘可以高亮选中柱子时柱子顶部可以有一个闪烁的光标。这需要在GamePanel_Paint方法中根据_selectedPeg等状态变量绘制不同的效果。5. 项目结构优化与设计模式思考当功能越来越多代码会变得混乱。是时候考虑一下代码的组织结构了。5.1 采用MVC/MVP模式进行重构目前MainForm窗体类既负责界面显示View又处理鼠标事件Controller还直接操作HanoiGame模型Model。我们可以尝试进行简单的分离。Model (模型):HanoiGame,Peg,Disk类保持不变它们只关心数据和规则。View (视图):MainForm主要负责UI控件的创建和布局以及触发绘制事件。它将用户输入如点击事件转发给控制器。Presenter/Controller (控制器): 新建一个GameController类。它持有HanoiGame实例和MainForm的引用或通过接口。当视图触发事件时控制器来调用模型的方法更新模型状态然后通知视图更新显示。这样做的好处是职责清晰单元测试更容易可以单独测试Model和Controller并且未来更换UI框架比如WPF时业务逻辑几乎不用改动。5.2 观察者模式更新UI在上面的MVC模式中视图如何更新一种优雅的方式是使用事件Event这本质上是观察者模式的一种实现。HanoiGame模型可以在状态改变时如圆盘移动、游戏重置触发事件。public class HanoiGame { public event EventHandler GameStateChanged; public event EventHandler GameWon; private void OnGameStateChanged() GameStateChanged?.Invoke(this, EventArgs.Empty); private void OnGameWon() GameWon?.Invoke(this, EventArgs.Empty); public bool TryMoveDisk(Peg fromPeg, Peg toPeg) { // ... 移动逻辑 ... if (success) { UserMoves; OnGameStateChanged(); // 通知订阅者状态已变 if (CheckWin()) OnGameWon(); return true; } return false; } }在MainForm或GameController中订阅这些事件一旦触发就更新标签文本并重绘画板。这样模型和视图就解耦了模型完全不知道UI的存在。5.3 数据持久化保存游戏记录我们可以增加一个功能将用户的游戏记录圆盘数、使用步数、是否完成、用时保存到本地文件或简单的数据库中。这涉及到序列化。public class GameRecord { public DateTime PlayTime { get; set; } public int DiskCount { get; set; } public int MovesUsed { get; set; } public int MinimumMoves { get; set; } public bool IsCompleted { get; set; } } public class RecordManager { private static string recordFilePath hanoi_records.json; private ListGameRecord _records; public RecordManager() { LoadRecords(); } public void AddRecord(GameRecord record) { _records.Add(record); SaveRecords(); } public ListGameRecord GetRecords() _records.OrderByDescending(r r.PlayTime).ToList(); private void LoadRecords() { if (File.Exists(recordFilePath)) { string json File.ReadAllText(recordFilePath); _records JsonSerializer.DeserializeListGameRecord(json) ?? new ListGameRecord(); } else { _records new ListGameRecord(); } } private void SaveRecords() { string json JsonSerializer.Serialize(_records, new JsonSerializerOptions { WriteIndented true }); File.WriteAllText(recordFilePath, json); } }在游戏胜利时创建一个GameRecord并调用RecordManager.AddRecord()。然后可以在主界面上增加一个“历史记录”按钮打开一个新窗口显示过往战绩。6. 常见问题、调试技巧与发布6.1 开发过程中的典型问题圆盘绘制错乱或重叠这几乎总是因为绘制顺序或坐标计算错误。仔细检查GamePanel_Paint中计算圆盘diskLeft和diskTop的逻辑。使用调试器在绘制循环中打印出每个圆盘的位置参数或者临时绘制辅助线来定位。鼠标点击无反应或选错柱子检查GamePanel_MouseDown中的命中测试逻辑。确保pegSpacing的计算与绘制时一致。考虑在调试时在点击处画一个红点并输出计算出的pegIndex看是否匹配。移动规则失效重点检查Peg.TryPush和HanoiGame.TryMoveDisk方法。确保TryPush中先检查是否为空再比较大小。可以在这些方法入口添加日志输出每次比较的参数和结果。自动演示动画卡顿或太快调整Timer.Interval属性。500毫秒半秒一步比较适中。如果步骤很多如8个圆盘有255步演示时间会很长可以考虑增加一个“加速/减速”按钮来动态调整Interval。内存泄漏在WinForms中事件订阅是内存泄漏的常见原因。确保窗体关闭时取消对所有定时器 (Timer) 和自定义事件如GameStateChanged的订阅。在窗体的FormClosing事件中停止并释放定时器。6.2 性能优化小技巧双缓冲在绘制复杂图形时可能会遇到闪烁问题。在GamePanel的构造函数中设置DoubleBuffered true;可以极大缓解闪烁。局部重绘Invalidate()会重绘整个控件。如果只是移动一个圆盘可以计算需要更新的区域源柱顶部和目标柱顶部使用Invalidate(Rectangle)进行局部重绘提高效率。但对于这个规模的应用全量重绘完全没问题。资源管理Graphics对象和Brush、Pen等GDI对象是非托管资源。在using语句中创建它们或者确保在Paint事件结束后它们会被妥善处理e.Graphics由系统管理我们创建的SolidBrush等需要手动处理。6.3 项目发布与分发完成开发后在Visual Studio中选择“生成” - “发布”可以选择发布为单个可执行文件这样分发起来更方便。.NET Framework项目会依赖对应版本的.NET Framework运行时需要确保目标电脑已安装。如果是.NET Core/.NET 5项目可以发布为独立部署将运行时一起打包文件会更大但兼容性更好。我个人在完成这个项目后最大的体会是将一个纯粹的算法问题工程化、产品化的过程远比写出递归函数本身更有挑战也更有收获。它迫使你思考数据结构、用户交互、状态管理和代码结构。当你看到自己写的程序从一个黑乎乎的控制台变成一个活生生的、可以点击和交互的桌面应用时那种成就感是无可替代的。你可以继续为它添加更多功能比如关卡选择、网络排行榜、更炫酷的动画效果这完全取决于你的想象力。这个项目就像一颗种子掌握了这些核心思路你完全可以用同样的方法去打造其他类型的桌面小游戏。