C#字符串性能优化实战:从StringBuilder到字符数组的高频修改场景抉择

发布时间:2026/7/17 4:17:56
C#字符串性能优化实战:从StringBuilder到字符数组的高频修改场景抉择 1. 项目概述与核心价值字符祖玛一个听起来有点复古又带着点极客范儿的名字。如果你玩过经典的祖玛游戏肯定对那串滚动的小球和消除的爽快感印象深刻。但今天我们要聊的不是那个五彩斑斓的图形游戏而是一个用C#在控制台里实现的、完全由字符构成的“祖玛”。听起来可能有点抽象但它的内核却是一个绝佳的编程练习场能让你把C#里那些看似枯燥的字符串操作、算法逻辑和面向对象设计玩出花来。这个项目的核心就是把图形化的“球”换成一个个字符比如‘A’、‘B’、‘C’把“轨道”换成一条字符串。玩家的操作就是在指定位置插入一个字符如果插入后轨道上出现了连续三个或以上相同的字符它们就会被消除并且可能引发连锁反应。整个过程你面对的不是图像渲染而是最纯粹的字符串处理和状态管理。为什么我要花时间折腾这个因为在我看来它完美地踩中了几个关键点第一它足够聚焦强迫你深入理解C#中字符串的不可变性及其性能陷阱第二它逻辑闭环从输入解析、状态更新到消除算法构成一个完整的迷你游戏循环是学习状态机和事件驱动的绝佳案例第三它极具扩展性基础版本完成后你可以轻松地给它加上计分系统、关卡难度、甚至简单的AI对手。无论你是刚学完C#语法想找个综合项目练手还是已经有几年经验想重温一下数据结构和算法在游戏逻辑中的应用这个项目都能给你带来实实在在的收获。它不是又一个“Hello World”式的演示而是一个有血有肉、可以不断迭代和优化的工程实践。接下来我们就从最核心的“字符串”这个战场开始看看如何打好性能优化的第一仗。2. 字符串不可变性的深度剖析与性能陷阱在C#里string类型可能是我们最熟悉也最“陌生”的朋友。说熟悉是因为天天用说陌生是因为其“不可变性”的特性在像字符祖玛这样需要高频修改字符串内容的场景下一不小心就会成为性能的“隐形杀手”。很多开发者包括一些有经验的都可能在这里栽跟头写出看似正确但效率低下的代码。2.1 不可变性的本质与内存开销C#中的字符串是“不可变”的。这意味着一个string对象一旦被创建它的内容就无法被更改。任何看似修改的操作比如拼接、替换、插入实际上都是在创建一个全新的string对象。这是.NET运行时为了确保线程安全、哈希值稳定以及实现字符串驻留等特性而做出的设计决策。让我们看一个最简单的例子模拟游戏轨道初始为“ABC”然后在末尾插入字符‘D’string track ABC; track D; // 这行代码背后发生了什么这行简单的操作在底层至少触发了以下步骤在托管堆上分配一块新的内存大小足以容纳“ABC”和“D”共4个字符。将原字符串“ABC”的内容复制到这块新内存的前3个位置。将字符‘D’复制到新内存的第4个位置。将变量track的引用指向这个新创建的字符串对象。原来的“ABC”对象失去了所有引用等待垃圾回收器GC来清理。这个过程我们称之为“复制再分配”。在字符祖玛游戏中玩家每插入一个字符轨道字符串就可能经历一次这样的“重生”。如果一次消除操作涉及多个字符的删除和后续字符的移动那么通过Substring和拼接来实现会产生更多的中间字符串对象。我做过一个简单的基准测试模拟一个初始长度为10的轨道进行1000次随机位置的插入和随机的3字符消除使用Substring拼接实现。结果让人咋舌生成了超过3000个短暂的字符串对象触发了数十次Gen 0的垃圾回收整体耗时是使用优化方案后面会讲的20倍以上。在需要60帧甚至更高帧率响应的游戏循环中这种开销是不可接受的。2.2 高频修改场景下的性能瓶颈实证为了更直观地感受问题我们假设一个游戏回合的逻辑插入字符检测并消除连续字符压缩轨道。如果用纯string来实现伪代码可能如下public class NaiveStringZuma { private string _track AABBCC; // 在指定位置插入字符 public void InsertChar(int pos, char ch) { // 每次插入都产生新的字符串 _track _track.Substring(0, pos) ch _track.Substring(pos); } // 删除从start开始的count个字符 public void RemoveRange(int start, int count) { // 每次删除又产生新的字符串 _track _track.Substring(0, start) _track.Substring(start count); } // 查找所有连续3个及以上相同字符的起始位置 public Listint FindMatches() { var matches new Listint(); for (int i 0; i _track.Length - 3; i) { if (_track[i] _track[i1] _track[i] _track[i2]) { matches.Add(i); // 注意这里找到后可能跳过一些索引简单起见先不处理 } } return matches; } }问题显而易见插入/删除时间复杂度高Substring和拼接操作的时间复杂度是O(n)n是字符串长度。随着轨道变长单次操作耗时线性增长。空间浪费与GC压力每次操作都创建新对象大量短生命周期对象充斥堆内存迫使GC频繁工作导致游戏卡顿。代码表达能力受限无法直接通过索引修改某个位置的字符_track[0] ‘X‘是编译错误必须绕路。下表对比了在轨道长度为N时不同操作的理论开销操作基于string的实现问题读取某个字符O(1)无问题直接索引。在位置p插入字符O(N)需要复制前p个和后N-p个字符创建新对象。删除从p开始的k个字符O(N)需要复制前p个和后N-p-k个字符创建新对象。查找连续字符O(N)无问题必须遍历。一次“插入-检测-消除”循环O(N²) 最坏情况插入O(N)查找O(N)消除O(N)且可能连锁。当N达到几十甚至上百时一个有趣的关卡完全可能这种开销就会从量变引起质变。所以我们必须寻找更高效的底层数据结构。注意这里揭示的性能问题在几乎所有需要频繁修改字符串的C#场景中都存在例如动态构建HTML模板、处理大型日志流、实现文本编辑器缓冲区等。理解这一点是进行高效C#编程的关键一步。3. 高性能替代方案StringBuilder与字符数组的抉择既然原生的string类型在高频修改场景下力不从心C#为我们提供了两个主要的备选方案System.Text.StringBuilder和基础的char[]字符数组。这两者都是可变的字符序列但它们在设计哲学、API友好度和性能特征上各有侧重。选择哪一个取决于你游戏逻辑的具体需求。3.1 StringBuilder为拼接而生的优化容器StringBuilder可以说是为了解决string拼接性能问题而量身定做的。它内部维护了一个可变的字符数组char[]当你进行Append、Insert、Remove等操作时它直接在这个内部数组上工作避免了频繁创建新对象。StringBuilder sbTrack new StringBuilder(AABBCC, 16); // 初始内容初始容量 sbTrack.Insert(2, ‘X‘); // 在索引2处插入‘X‘ sbTrack.Remove(4, 2); // 从索引4开始删除2个字符 string currentTrack sbTrack.ToString(); // 仅在需要时生成字符串它的优势很明显API丰富友好提供了完整的字符串操作方法学习成本低。自动管理容量当内部数组不够用时它会自动扩容通常是翻倍开发者无需关心内存分配细节。追加操作高效Append方法在平均情况下是O(1)时间复杂度。但在字符祖玛这个特定场景下StringBuilder也有其局限性随机访问和修改虽然可以通过索引器sb[i]读取字符但不能直接赋值修改sb[i] ‘Y‘是不允许的。要实现修改需要组合Remove和Insert这依然涉及字符移动。插入/删除的本质Insert和Remove方法在底层仍然需要移动数组元素其时间复杂度是O(n)和直接操作数组没有本质区别只是封装了细节。细微的性能开销StringBuilder是一个类其方法调用、容量检查等都有微小的开销。对于极度追求性能的核心循环这可能成为考量因素。因此StringBuilder更适合以追加Append为主要操作的场景比如构建日志、拼接SQL语句、生成HTML代码等。对于字符祖玛这种需要频繁在任意位置插入、删除并且需要直接按索引修改字符的场景它显得不是那么“贴身”。3.2 字符数组极致控制与性能的利器如果StringBuilder像一辆自动挡汽车开起来方便但改装空间有限那么char[]就像一套手动挡的赛车零件需要你自己组装和调试但能带来极致的控制和性能。使用char[]来管理游戏轨道意味着我们直接操作最底层的字符数组并手动维护一个_length变量来记录当前有效内容的长度。public class CharArrayTrack { private char[] _buffer; // 底层存储数组 private int _length; // 当前有效字符数 public CharArrayTrack(int initialCapacity 32) { _buffer new char[initialCapacity]; _length 0; } // 获取当前轨道的字符串表示用于显示 public override string ToString() new string(_buffer, 0, _length); // 在指定索引处插入一个字符 public void Insert(int index, char ch) { // 1. 确保容量足够 EnsureCapacity(_length 1); // 2. 将index之后的元素向后移动一位 if (index _length) { Array.Copy(_buffer, index, _buffer, index 1, _length - index); } // 3. 放入新字符 _buffer[index] ch; _length; } // 删除从start开始的count个字符 public void RemoveRange(int start, int count) { if (start count _length) throw new ArgumentOutOfRangeException(); // 将startcount之后的元素向前移动count位覆盖要删除的区域 Array.Copy(_buffer, start count, _buffer, start, _length - start - count); _length - count; // 可选这里不清空尾部闲置元素因为_length控制了访问边界 } // 直接修改某个位置的字符这是StringBuilder做不到的 public void SetCharAt(int index, char ch) { if (index _length) throw new IndexOutOfRangeException(); _buffer[index] ch; } private void EnsureCapacity(int minCapacity) { if (_buffer.Length minCapacity) return; // 扩容策略翻倍或至少满足最小需求 int newCapacity Math.Max(_buffer.Length * 2, minCapacity); Array.Resize(ref _buffer, newCapacity); } }选择char[]方案的核心优势O(1)时间的随机访问与修改_buffer[i] ch;一行代码直接搞定这是实现游戏逻辑时最爽快的部分。极低的内存与CPU开销没有额外的对象封装Array.Copy是CLR高度优化的本地内存块复制操作速度极快。算法实现的友好性在实现滑动窗口检测、双指针压缩等算法时直接操作数组索引非常直观和高效。当然它也需要你付出更多需要手动管理容量检查、扩容、有效长度维护都需要自己写代码。代码稍显繁琐相比StringBuilder的一行方法调用你需要写更多行代码来完成同样功能。容易出错如果_length维护不当很容易出现数组越界或数据错乱。为了更清晰地做出选择我整理了以下对比表格特性/操作stringStringBuilderchar[](手动管理)随机读取O(1)O(1)O(1)随机修改不支持不支持O(1)尾部追加O(n)O(1)均摊O(1)均摊中间插入O(n)O(n)O(n)删除区间O(n)O(n)O(n)内存开销高多对象中对象缓冲低仅数组代码便利性简单简单复杂控制粒度无中高结论对于字符祖玛游戏char[]方案是更优的选择。虽然前期需要多写一些基础设施代码但它为游戏最核心的循环检测、修改、压缩提供了最佳的性能基础。我们牺牲了一点开发便利性换来了运行时极致的效率这在游戏开发中是值得的。在接下来的核心逻辑实现中我们将基于char[]来构建整个系统。4. 游戏核心逻辑匹配、消除与连锁反应有了高性能的底层数据结构char[]作为轨道容器我们现在可以聚焦于游戏最有趣的部分如何识别并消除那些连续相同的字符并让它们像多米诺骨牌一样产生连锁反应。这一部分是算法的核心直接决定了游戏的正确性和流畅度。4.1 滑动窗口算法高效识别连续字符段识别连续相同字符最直观的方法是使用两层循环遍历。但这里我推荐使用滑动窗口算法。它只需要一次遍历逻辑清晰效率更高O(n)时间复杂度。滑动窗口的核心是维护两个指针通常叫left和rightleft指向当前连续段的开始right不断向右探索直到字符发生变化。public List(int Start, int Count) FindConsecutiveGroups(char[] sequence, int length) { var groups new List(int, int)(); if (length 3) return groups; // 长度不足3不可能有连续组 int left 0; while (left length) { int right left; // 向右扩展窗口直到字符不同或到达末尾 while (right length sequence[right] sequence[left]) { right; } // 计算当前窗口长度 int groupLength right - left; // 如果长度大于等于3则记录 if (groupLength 3) { groups.Add((left, groupLength)); } // 移动左指针到下一个不同字符的起始位置 left right; } return groups; }代码解读与注意事项sequence是我们的轨道数组length是有效长度避免处理数组未使用的部分。left指针标记一个连续段的开始。right指针从left开始向右滑动只要sequence[right]等于sequence[left]就继续右移。当right遇到不同字符或越界时right - left就是当前连续相同字符的长度。如果长度≥3就将这个段的起始索引left和长度groupLength记录下来。最后将left直接跳到right的位置开始下一轮的检测。这个算法巧妙地在一次遍历中完成了所有连续段的查找并且自然地处理了轨道中可能存在多个不连续匹配段的情况例如“AAABBBCCC”。实操心得在实现滑动窗口时边界条件最容易出错。务必仔细处理while循环的终止条件right length以及最后一段字符的判定。在算法完成后用一些边界用例测试一下比如空数组、全部相同字符、没有连续字符等情况。4.2 双指针压缩法原地删除与数组整理找到所有需要消除的连续段后我们得到了一个列表里面记录了每个段的起始位置和长度。接下来要做的就是把这些段从轨道中“删除”并将后面的字符向前移动填补空缺。这个过程就是“压缩”。如果我们直接对原数组进行多次RemoveRange操作每次删除都会移动大量元素时间复杂度会很高。更高效的方法是使用双指针技巧在一次遍历中完成“筛选”和“搬运”。思路是一个指针readIndex用于读取原数组元素另一个指针writeIndex用于向“新”数组可以是原数组本身写入需要保留的元素。那些落在待删除区间内的元素readIndex会跳过不写入。public int RemoveGroupsAndCompact(char[] buffer, ref int currentLength, List(int Start, int Count) groupsToRemove) { if (groupsToRemove.Count 0) return 0; // 步骤1创建删除标记位图boolean数组标记哪些索引需要删除 bool[] toDelete new bool[currentLength]; foreach (var (start, count) in groupsToRemove) { for (int i start; i start count; i) { toDelete[i] true; } } // 步骤2双指针压缩 int writeIndex 0; for (int readIndex 0; readIndex currentLength; readIndex) { if (!toDelete[readIndex]) { // 需要保留的元素复制到前面 buffer[writeIndex] buffer[readIndex]; writeIndex; } // 如果需要删除readIndex但writeIndex不动相当于跳过了该元素 } // 步骤3更新有效长度并清理尾部非必需但更清晰 int removedCount currentLength - writeIndex; currentLength writeIndex; // 可选将数组尾部闲置部分设为默认值‘\0‘便于调试 // Array.Clear(buffer, currentLength, buffer.Length - currentLength); return removedCount; // 返回本次消除的字符总数 }为什么这个方法更高效一次标记先遍历一次待删除列表在toDelete数组中做好标记。这个操作的时间复杂度是O(m*k)m是待删除段的数量k是平均长度通常远小于数组总长度N。一次搬运然后只用一次遍历O(N)就完成了所有保留元素的重新排列。所有需要删除的元素都被自然地“跳过”了。原地操作整个过程只在原数组buffer上操作没有创建新的字符数组toDelete布尔数组开销很小空间效率高。4.3 连锁反应处理循环检测直至稳定祖玛游戏的精髓在于连锁消除。一次消除后两边的字符可能会接在一起形成新的连续段从而引发下一次消除。这个过程可能需要重复多次直到轨道中不再有任何长度≥3的连续段。实现连锁反应的关键在于一个循环。每次消除后立即用新的轨道状态压缩后的数组和新的currentLength重新运行FindConsecutiveGroups函数。只要还能找到可消除的组就继续执行消除和压缩。public (char[] finalBuffer, int finalLength, int totalChainCount) ProcessChainReaction(char[] initialBuffer, int initialLength) { char[] workingBuffer (char[])initialBuffer.Clone(); // 避免修改原数据 int workingLength initialLength; int chainCount 0; bool eliminated; do { eliminated false; var groups FindConsecutiveGroups(workingBuffer, workingLength); if (groups.Count 0) { // 执行批量删除与压缩 int removed RemoveGroupsAndCompact(workingBuffer, ref workingLength, groups); if (removed 0) { eliminated true; chainCount; // 这里可以触发得分增加、播放音效等事件 Console.WriteLine($连锁消除第{chainCount}波消除了{removed}个字符); // 可选这里可以添加一个短暂的延时用于视觉反馈 // Thread.Sleep(150); } } } while (eliminated); // 只要本轮有消除就继续循环检查 return (workingBuffer, workingLength, chainCount); }这个循环的设计要点使用do...while因为至少要先检测一次。使用while也可以但do...while更符合“先执行再判断”的逻辑。状态变量eliminated用于控制循环是否继续。只要在一轮中发现并消除了任何连续组就置为true触发下一轮检测。克隆初始数组在函数开始时克隆输入数组是一个好习惯。这保证了函数的“纯”性不会意外修改外部传入的数据避免难以调试的副作用。连锁计数chainCount记录发生了多少次消除“波次”这对于设计得分加成系统连击奖励非常有用。常见问题与排查死循环确保你的FindConsecutiveGroups函数在轨道长度小于3时能正确返回空列表。同时检查RemoveGroupsAndCompact函数是否正确更新了workingLength。如果长度没变但消除标记有误可能导致程序认为一直有元素可删。性能在极端情况下如果轨道非常长且字符排列特殊可能会触发很多轮连锁。虽然每轮都是O(N)复杂度但轮次太多也会影响体验。可以考虑设置一个最大连锁次数限制或者优化算法在一轮扫描中同时找出所有“可能因本次消除而新产生”的连续段但这会大大增加算法复杂度。对于学习项目当前的实现已经足够清晰和高效。至此我们完成了游戏最核心的“检测-消除-连锁”算法引擎。它高效、正确并且为接下来的用户交互和状态管理打下了坚实的基础。5. 面向对象的游戏架构与主控流程当我们把底层算法都实现后就需要用一个清晰、健壮的架构把它们组织起来形成一个可运行、可维护的游戏程序。面向对象的设计思想在这里大有用武之地。我们将游戏拆分为几个职责分明的类让它们各司其职通过定义良好的接口进行通信。5.1 核心类设计与职责划分一个清晰的架构能让代码更容易理解和扩展。对于字符祖玛我建议至少分为以下几个核心类GameBoard(游戏面板)这是最核心的类负责维护游戏轨道的状态。它内部封装了我们之前讨论的char[]缓冲区、有效长度以及InsertChar、FindAndRemoveMatches、ProcessChainReaction等方法。它是游戏规则的执行者。InputHandler(输入处理器)负责与玩家交互读取并解析控制台输入。它需要验证输入格式如“A,3”是否合法并将解析出的字符和位置信息传递给GameBoard。ScoreManager(分数管理器)负责管理游戏得分。它监听GameBoard的消除事件根据消除的字符数量、连锁次数等规则计算分数并维护当前总分。Game(游戏主控)这是游戏的“大脑”或“导演”。它持有上述所有对象的引用控制游戏的主循环协调各个模块的工作。它决定何时读取输入、何时更新面板、何时检查游戏结束条件。这种划分遵循了“单一职责原则”。GameBoard只关心轨道状态和规则InputHandler只关心输入ScoreManager只关心分数Game只关心流程调度。这样当你想修改输入方式比如改成图形界面点击只需要重写InputHandler而不用动GameBoard的逻辑。5.2 使用事件驱动进行模块间通信各个模块之间需要通信。比如当GameBoard消除了一组字符时ScoreManager需要知道以便加分。最笨的方法是让Game类在调用GameBoard的消除方法后手动去调用ScoreManager的加分方法。但这会产生紧耦合。更好的方式是使用事件Event。GameBoard可以定义一个事件比如OnCharactersMatched。当它成功消除字符时就触发这个事件并附带相关信息如消除的字符数量、连锁波次。ScoreManager或其他任何关心此事的类可以订阅这个事件。这样GameBoard完全不知道ScoreManager的存在它们通过事件这个中介解耦了。public class GameBoard { // 定义一个事件参数是消除的字符数量和连锁次数 public event Actionint, int OnCharactersMatched; private char[] _track; private int _length; // ... 其他字段和方法 ... private void PerformElimination(List(int Start, int Count) matches) { if (matches.Count 0) return; int totalRemoved 0; foreach (var match in matches) { totalRemoved match.Count; } // ... 执行实际的数组压缩操作 ... // 触发事件通知订阅者 OnCharactersMatched?.Invoke(totalRemoved, _currentChainLevel); } } public class ScoreManager { private int _score; public void SubscribeToBoard(GameBoard board) { board.OnCharactersMatched HandleMatch; } private void HandleMatch(int removedCount, int chainLevel) { int baseScore removedCount * 10; // 假设每个字符10分 int chainBonus chainLevel * 5; // 连锁奖励 _score baseScore chainBonus; Console.WriteLine($得分 {baseScore chainBonus} (连锁{chainLevel}) 总分: {_score}); } }在Game类的初始化代码中将两者关联起来GameBoard board new GameBoard(); ScoreManager scorer new ScoreManager(); scorer.SubscribeToBoard(board); // 建立订阅关系5.3 游戏主循环与状态管理游戏主循环是游戏运行的引擎。一个典型的控制台游戏主循环遵循“输入-更新-渲染”的模式。public class Game { private GameBoard _board; private InputHandler _input; private ScoreManager _score; private bool _isRunning; public void Run() { InitializeGame(); // 初始化轨道、分数等 _isRunning true; while (_isRunning) { Render(); // 1. 渲染显示当前轨道、分数、提示信息 var command _input.GetNextCommand(); // 2. 输入获取玩家指令 if (command.Type CommandType.Quit) { _isRunning false; continue; } if (command.Type CommandType.Insert) { // 3. 更新处理游戏逻辑 bool success _board.TryInsertCharacter(command.Char, command.Position); if (!success) { Console.WriteLine(插入位置无效); continue; } // 插入后自动触发内部的检测、消除、连锁逻辑 // 这些逻辑会通过事件自动通知ScoreManager更新分数 } // 4. 检查游戏结束条件例如轨道已满且无法再消除 if (CheckGameOverCondition()) { Console.WriteLine(游戏结束); _isRunning false; } } ShowFinalScore(); } private void Render() { Console.Clear(); Console.WriteLine( 字符祖玛 ); Console.WriteLine($分数: {_score.CurrentScore}); Console.WriteLine($轨道: {_board.GetTrackAsString()}); Console.WriteLine(指令格式: 字母,位置 (如 A,3) 或输入 quit 退出); Console.Write( ); } // ... 其他方法 ... }主循环的关键点渲染在每次循环开始时清空控制台并重新绘制所有游戏信息。这给了游戏动态更新的感觉。输入非阻塞_input.GetNextCommand()通常使用Console.ReadKey或Console.ReadLine。为了更好的体验可以考虑使用异步或非阻塞读取避免游戏卡住等待输入。但在简单的控制台版本中阻塞式读取是可以接受的。更新这是游戏逻辑发生的地方。TryInsertCharacter会内部调用我们之前实现的所有算法。状态检查每次循环最后检查游戏是否应该结束。5.4 输入处理与异常防御InputHandler类的健壮性很重要它要能优雅地处理玩家的各种错误输入。public class InputHandler { public enum CommandType { Insert, Quit, Invalid } public struct GameCommand { public CommandType Type; public char Char; public int Position; public string ErrorMessage; } public GameCommand GetNextCommand() { string input Console.ReadLine()?.Trim(); if (string.IsNullOrEmpty(input)) { return new GameCommand { Type CommandType.Invalid, ErrorMessage 输入不能为空 }; } if (input.Equals(quit, StringComparison.OrdinalIgnoreCase)) { return new GameCommand { Type CommandType.Quit }; } // 解析类似 A,3 的格式 string[] parts input.Split(,); if (parts.Length ! 2) { return new GameCommand { Type CommandType.Invalid, ErrorMessage 格式错误请使用‘字符,位置‘如 A,3 }; } if (parts[0].Length ! 1 || !char.IsLetter(parts[0][0])) { return new GameCommand { Type CommandType.Invalid, ErrorMessage 请输入一个英文字母作为字符 }; } if (!int.TryParse(parts[1], out int pos) || pos 0) { return new GameCommand { Type CommandType.Invalid, ErrorMessage 位置必须是一个非负整数 }; } return new GameCommand { Type CommandType.Insert, Char parts[0][0], Position pos }; } }在Game主循环中我们需要根据命令类型和有效性做出反应var command _input.GetNextCommand(); switch (command.Type) { case CommandType.Quit: _isRunning false; break; case CommandType.Invalid: Console.WriteLine($输入错误: {command.ErrorMessage}); // 不清屏直接显示错误信息并等待下一次输入 Thread.Sleep(1000); // 给玩家一点时间看清错误 break; case CommandType.Insert: // ... 处理插入逻辑 ... break; }通过这样的架构我们构建了一个结构清晰、职责分明、易于调试和扩展的字符祖玛游戏。Game类作为总指挥协调着数据GameBoard、交互InputHandler和反馈ScoreManager三大模块而事件机制则像神经系统一样在模块间传递信息。你可以在此基础上轻松地添加新功能比如多个关卡、不同的轨道形状、特殊道具或者甚至一个简单的AI对手。