
简介单机版抢车位游戏C#语言是一份基于 C# 开发的桌面小游戏源码项目面向 C# 初学者、面向对象编程练习者以及想改造小游戏规则或界面的开发者。资源包共 83 个文件、约 6.37MB其中包含 15 个 .cs 源文件、37 张 PNG 与 9 张 JPG 界面素材、3 组 resx/resources 窗体资源以及可直接运行的 exe、界面功能说明文档和玩家对象初始化文本结构比一般单文件示例完整便于对照代码与运行效果学习。目前已有 239 人学习下载。项目刻意保留完整源码可随时查看和修改通过 Player、Car、ParkingSpace 等类的协作能直观理解封装、继承、多态等 OOP 设计界面功能说明和初始化玩家对象文本则能帮助梳理游戏流程在此基础上加入计时器、特殊车位、随机事件等扩展也较为方便是一份适合入门 C# 游戏开发的实战样本。 写了这么多年 C#大多时候都在跟业务系统、后端服务打交道。前阵子休息突然想换个思路练练手于是用 C# 写了个单机版抢车位游戏。这个项目看起来小但做下来发现里面涉及的控制台交互、随机逻辑、字符串处理、对象建模几乎把 C# 的基础知识串了一遍非常适合用来巩固面向对象思维和常用 API 的使用。如果你正想找一个“有点意思又不至于太难”的 C# 练手项目或者想教身边的朋友体验一把编程带来的即时反馈单机版抢车位是个特别好的切入点。核心思路不复杂玩家和 AI 车辆在有限车位上竞争谁先到位、谁先确认车位就是谁的。没有网络、没有服务端一台电脑就能跑。但真正动手做的时候会遇到很多隐蔽的细节坑比如控制台渲染闪烁、随机数不随机、字符串截取出错导致界面错位等等。这篇文章我直接把整个设计和排坑过程拆开讲从规则设计到核心代码再到几个让我印象深刻的 Bug 排查链路都给出来。1. 抢车位的核心玩法拆解不写界面之前先把规则想透真正动手写代码之前我花了大半天时间在想一件事单机版怎么做出“抢”的感觉玩家是人AI 是程序如果 AI 永远固定速度、固定路线那玩家玩两局就会觉得假。反过来如果 AI 快得离谱玩家连按键的机会都没有挫败感又太强。1.1 “抢”的本质三种时序模型我最开始想到的是最简单直接的方案所有目标车位相同谁先按下确认键谁赢。这就是所谓的“按键窗口期模型”程序在某一轮里随机生成一个车位和一个时间窗口玩家和 AI 各自有一个“反应时间”当倒计时进入可抢区间后谁先在窗口内按下空格谁就抢到。这个模型实现最简单但问题在于策略几乎为零玩十分钟就会腻。于是我把模型升级成了“独立到达时间模型”。每个车位有自己的状态玩家和一辆或者多辆 AI 车同时对某个空位产生兴趣。程序为每辆车生成一个到达时间这个时间由“路程耗时”和“反应时间”组成。玩家到达后还需要在 2 秒内按下确认键按早了会被判为无效操作按晚了 AI 可能已经停进去。这个模型最大的好处是玩家可以选择去争抢“距离自己近”的车位也可以剑走偏锋赌一个远车位没人抢玩法一下子立体起来。后来我又加了一层“锁定状态”当某辆车进入某车位的最后 1 米范围对应到程序里就是倒计时末尾 500 毫秒车位会进入锁定状态其他车不能再抢。这个机制借鉴了很多多人竞技游戏的“后出手保护”防止玩家和 AI 同时到达时拼网络延迟。单机版其实不存在网络延迟但这种锁定设计让胜负判定更清晰也方便写游戏结束的反馈逻辑。1.2 奖惩数值与博弈节奏规则框架定了数值设计又花了些心思。我设计了一套简单的计分系统抢到一个普通车位得 100 分抢到红色 VIP 车位得 250 分连续三次成功抢到车位额外奖励 50 分五次尝试都没抢到进入 10 秒“手冷状态”这期间按键判定时间缩短到 1 秒。这一套数值并不是我拍脑袋定的而是实际操作后迭代出来的。最初版本没有连击奖励玩家普遍反映“赢了没爽感”后来加了连击又发现玩家为了拿连击奖励会过度冒进所以加了“连续失败惩罚”让玩家在激进和稳妥之间做取舍。这里我建议你做数值时也遵循一个原则每个奖励或惩罚都必须对应一种可感知的玩家行为。如果某个规则加了玩家完全感觉不到那它就是一个多余的复杂度。我用一个 Excel 表记录每局测试的数据连续测了十几轮才把分数和惩罚数值调到比较舒服的节奏。提示这个游戏项目的核心不是“把界面做得好看”而是把“抢”这一瞬间的体验打磨好。你可以在后期用 WinForms 或 WPF 重绘界面但游戏内核部分用纯控制台足以承载。2. 车辆、车位与游戏循环C# 数据结构的落地选型规则想清楚后就到了选数据结构的环节。很多人写小游戏喜欢把所有状态都堆在一个类里Plan 车进去了、Bike 又进去了、GameState 也全塞进去。我的建议是即使项目很小也把每个核心概念抽成独立的类因为后续每加一个功能你会发现清晰的边界能帮你省掉大量改 bug 的时间。2.1 用枚举和类建模别急着上数据库车位本身我用了一个枚举来表示当前状态再加一个类来承载业务数据public enum SlotStatus { Empty, Locked, Occupied } public class ParkingSlot { public int SlotId { get; set; } public string SlotCode { get; set; } // 如 A-01 public SlotStatus Status { get; set; } public bool IsVip { get; set; } public string OwnerPlateNo { get; set; } public int ScoreValue { get; set; } public void Reset() { Status SlotStatus.Empty; OwnerPlateNo string.Empty; } }车辆类也做了简化但保留了两个关键字段public class Vehicle { public string PlateNo { get; set; } public string ModelName { get; set; } public int ArriveTimeMs { get; set; } // 到达目标车位所需时间 public int ReactionTimeMs { get; set; } // AI 确认按键的反应时间 }有朋友问我为什么不用数据库存车位数据对这种单机小项目集合就够了。我用了一个ListParkingSlot配合Dictionaryint, Vehicle保存每辆车和车位的对应关系这样做的原因在于单机版游戏的生命周期只有几分钟数据量极小连 SQLite 都属于过度设计。等到你真要扩展成局域网联机版再迁移数据库也不迟游戏逻辑类和数据类边界清晰迁移成本很低。2.2 游戏主循环与事件驱动控制台游戏的主循环本质上是一个“轮询 状态搬运”的过程。我不建议在这种场景里用真正的事件驱动或多线程原因后面会说。我采用的是while (true)Thread.Sleep(50)轮询模式while (!gameOver) { Render(); HandleInput(); UpdateAIDrivers(); CheckCollisionAndScore(); Thread.Sleep(50); }为什么不用多线程让每个 AI 车都跑一个独立的 Timer因为控制台程序的输入是阻塞的多线程很容易出现同时修改控制台光标位置的竞态问题。到时候你看到的就是屏幕上车位状态还没刷新完玩家输入又进来了整个界面乱成一团。轮询虽然在 CPU 利用率上不如事件驱动“优雅”但对这种单机小游戏来说稳定性和开发效率都更好。50ms这个刷新间隔也是有讲究的。我试过10msCPU 占用偏高还感觉不到流畅度提升试过200ms倒计时显示明显掉帧。50ms相当于 20FPS对字符界面来说已经非常顺手。2.3 车位编号格式化与字符串处理的第一次相遇在控制台界面里车位编号要显示成A-01、B-12这种格式。第一版我偷懒直接拼接A- slotId结果 1 号车位永远显示成A-1整个表格的右边界参差不齐逼死强迫症。后来我改成了标准格式化string slotCode ${(char)(A areaIndex)}-{slotId:D2};D2这个格式说明符会把数字补成两位所以 1 变成01。这种细节看起来微不足道但玩起来观感差异很大。类似的问题还出现在车牌号生成和输入解析上下面第 5 章的踩坑部分我会专门展开讲。3. 核心随机算法如何让 AI“抢位”看起来像真人单机版最大的挑战不是让玩家赢而是让 AI 看起来像个有情绪的真人而不是一个精确到毫秒的机器人。如果 AI 每次都是准点到达、准点确认玩家会很受挫。如果你把 AI 调得太迟钝又像在欺负小学生。这里的关键在随机算法。3.1 随机延迟与加权概率C# 的Random类是最常用的随机来源但它有几个使用上的坑。第一不要在循环里频繁new Random()否则很多情况下你会得到相同的种子导致 AI 每次行为一模一样。第二Random.Next()生成的是均匀分布而人的反应时间更接近正态分布大多数时候在平均值附近偶尔才会特别快或特别慢。我的 AI 反应时间生成逻辑是这样的private static int GenerateReactionTime(Random rng, int baseMs, int varianceMs) { // 用三个均匀分布随机数的平均值来近似正态分布 double u (rng.NextDouble() rng.NextDouble() rng.NextDouble()) / 3.0; int offset (int)((u - 0.5) * 2 * varianceMs); return Math.Clamp(baseMs offset, baseMs / 2, baseMs varianceMs); }这里为什么不用正态分布库因为项目不需要那么高的数学精度三个NextDouble()取平均已经能产生“大多数 AI 在平均值附近偶尔有快有慢”的分布特征。考虑到可读性这种近似方案反而更好别人看代码一眼就懂。另外我给不同等级的 AI 设置了不同参数AI 等级基础反应时间波动范围行为倾向新手1200ms400ms经常犹豫对 VIP 车位不敏感中等750ms300ms会优先选择空位多区域高手450ms150ms抢 VIP 概率高动作干脆你实际测试时会发现新手 AI 在后期几乎抢不到 VIP因为高手 AI 的反应窗口太短了。为了解决这个问题我还给每个 AI 加了一个“性格标签”比如“谨慎型”AI 在锁定状态下车位前会犹豫一下重新评估是否放弃“激进型”AI 则会在 50% 概率下顶着锁定风险硬冲。这些游戏手感的调节都是靠概率参数堆出来的比写死一套固定脚本灵活得多。3.2 “手速对抗”的实现按键窗口期当玩家选择了一个目标车位后程序会进入一个“确认等待期”。这期间控制台会显示一个进度条进度条走到白色区域时玩家按空格才是有效操作。这个窗口期本质上是两个随机值窗口开始时间由车位到玩家的“距离”换算窗口宽度固定 500ms但受玩家当前状态影响连续失败惩罚会让窗口缩到 300ms。判定逻辑用时间戳实现代码里不依赖精确计时器而是每次主循环检查Environment.TickCount64是否落在窗口区间内long now Environment.TickCount64; if (now windowStart now windowStart windowWidthMs) { if (Console.KeyAvailable Console.ReadKey(true).Key ConsoleKey.Spacebar) { HandlePlayerConfirm(); // 有效操作 } }为什么用Environment.TickCount64而不是DateTime.Now因为DateTime.Now的精度虽然够但它的开销更大而且受系统时间修改的影响。游戏循环里每 50ms 就跑一次TickCount64是专门为这种场景设计的。4. 控制台界面的交互设计与实用封装控制台程序最容易做丑也最容易出 bug。丑没关系但交互混乱就致命了。我最终实现的界面分三个区域上方车位地图、中间事件日志、下方操作提示。三个区域用固定的坐标系绘制而不是每次都Console.Clear()重画整个屏幕。4.1 基于 Console 的界面布局我封装了一个小的渲染类public class ConsoleRenderer { private readonly int _mapTop 2; private readonly int _logTop 12; private readonly int _inputTop 20; public void DrawFrame() { Console.CursorVisible false; Console.SetCursorPosition(0, 0); Console.WriteLine( 单机版抢车位 ); Console.WriteLine(区域A: [A-01] [A-02] [A-03] ...); Console.SetCursorPosition(0, _logTop); Console.WriteLine(--- 实时事件 ---); Console.SetCursorPosition(0, _inputTop); Console.WriteLine(--- 操作 ---); } }第一次运行时画出静态框架后续每一帧只在需要变化的位置更新文本。比如车位状态改变了就重新定位到某个车位对应的列覆盖写入新的字符串。这种做法比Console.Clear()高效很多而且能避免整屏闪烁。4.2 输入处理与防误触控制台输入一定要处理两个问题输入缓冲残留和脏数据读取。我用Console.KeyAvailable来判断“有没有按键可读”再配合Console.ReadKey(true)读取。true参数表示不把按键回显到屏幕上否则玩家按一个方向键屏幕上就会多一个奇怪的字符非常影响体验。另一个容易被忽略的问题是“按键抖动”。玩家在紧张的时候可能会在几百毫秒内连按多次空格第二次按键可能落在窗口期之外反而造成失败判定。我的处理是每次有效按键后加入 200ms 的锁定期private long _lastKeyTime; private bool IsKeyInputLocked() { long now Environment.TickCount64; if (now - _lastKeyTime 200) return true; _lastKeyTime now; return false; }这个 200ms 是我实测了多次后选择的数值。太短起不到防抖作用太长则会让玩家觉得“我按了没反应”。另外在窗口期之外不管玩家按了什么都不应产生任何提示音或错误反馈减少挫败感。5. 实战踩坑从能跑到能玩之间的距离这一部分我想详细讲三个我实际踩过的坑每个坑都折腾了我不少时间而且特别具有代表性。如果你自己写控制台小游戏大概率也会遇到。5.1 坑一控制台界面闪烁与光标乱跳现象第一版我用了最粗暴的Console.Clear()重画结果运行起来整个屏幕疯狂闪烁光标还时不时跳到意想不到的位置。当时第一反应是电脑卡了后来才意识到是清屏重绘导致的刷新率太低加上线程调度不稳定。排查链路我先是尝试在Console.Clear()后加Thread.Sleep(10)试图降低刷新频率结果屏幕不闪了但手感变差按键响应延迟明显。接着我做了个实验在每一帧开始和结束时分别记录Console.CursorTop发现重绘期间光标位置在几个区域之间来回跳。修复方案彻底放弃Console.Clear()改成“全量静态绘制一次 局部动态更新”。具体实现就是 4.1 里的渲染器。我还在每次局部更新前设置Console.SetCursorPosition(x, y)刷完立刻恢复原位避免光标轨迹干扰画面。Console.CursorVisible false也是在这里加的光标隐藏后整个界面干净很多。这个坑的教训是控制台不是浏览器不要指望它有自动 diff 重绘的能力。你把它当成一个手工维护的字符画板反而能写出更可控的界面。5.2 坑二字符串截取导致的车位号错位现象有一次测试发现公告栏里显示“玩家抢到了车位 A-10”但地图上 A-10 并没有变成占用状态反而 A-01 变成了红色。一开始我以为是数据绑定错了查了很久才发现问题出在字符串截取。排查链路我用的输入解析逻辑是让玩家输入类似A10这样的简写然后程序从中截取区域字母和数字string input Console.ReadLine().Trim(); string areaLetter input.Substring(0, 1).ToUpper(); int slotNumber int.Parse(input.Substring(1));看起来没问题对吧但玩家输入A-10的时候界面提示明明是A-01格式玩家很容易带上横杠Substring(1)截出来的是-10int.Parse直接抛异常。而我当时为了省事在Parse外面包了一层try-catch异常被吞掉后slotNumber保持了上一次的值于是游戏就把上一次操作的车位当成了目标最终抢错了对象。修复方案两个层面修。输入先用Replace(-, )把所有横杠去掉再统一用Substring做长度判断。如果解析失败直接返回错误提示绝不吞异常更不能用上一次的脏数据继续跑游戏流程。同时我把输入格式限制做成“要么 A-01要么 A01要么 a-01”都先走一遍统一清洗input input.Trim().Replace(-, ).Replace( , ).ToUpper(); if (input.Length ! 3 || !char.IsLetter(input[0]) || !char.IsDigit(input[1]) || !char.IsDigit(input[2])) { ShowError(输入格式不正确示例A-01 或 A01); return; } string areaLetter input.Substring(0, 1); int slotNumber int.Parse(input.Substring(1));这个坑对你最大的提醒是涉及到Substring截取字符串时一定先验证字符串长度和格式不要直接就截。try-catch是为了异常兜底不是用来掩盖业务逻辑错误的。5.3 坑三随机种子重复导致 AI“一秒抢完”现象游戏第三次大测试时突然出现一整轮 AI 车全部在一瞬间完成抢位的诡异情况。我甚至怀疑是不是自己把时间单位搞错了把所有ms当成s来用了。排查链路我沿着生成 AI 车辆的代码一路看发现问题出在一个初始化方法里for (int i 0; i vehicleCount; i) { var ai new Vehicle(); ai.ArriveTimeMs new Random().Next(300, 1800); // ... }每一辆 AI 车都new了一个新的Random()。在 .NET 里连续快速创建Random对象时默认种子基于当前时间戳而循环内创建的时间间隔太短多个Random拿到的种子几乎一样于是所有 AI 车的到达时间都相同。看起来就是“一秒抢完”。修复方案整个游戏进程中只创建一个静态的Random实例所有需要随机数的地方都复用它public static class GameRandom { public static readonly Random Instance new Random(); }这个坑在 .NET 面试题里经常出现但真到写项目的时候很多人还是会踩。它的本质是“资源重复创建 种子时间相关性”叠加的问题。另外如果你的应用是多线程的需要注意Random不是线程安全的但在这个单线程轮询模型里没有这个问题。6. 后续可以玩出的花样核心版本跑通之后我做了几个小扩展每个都不难但效果很好。你可以根据自己的兴趣选着做数据持久化用System.Text.Json把每局游戏的得分、车位占用历史、玩家操作次数序列化到本地 JSON 文件下次启动时可以展示历史战绩。这比引入数据库轻巧得多。自定义难度把 AI 等级参数抽到配置文件里让玩家可以调“AI 反应速度”“VIP 车位概率”“窗口期宽度”。我现在直接用appsettings.json读取没有额外引入程序包。技能系统每抢到三个车位获得一次“一键占位”技能用它可以立即锁定一个空车位但技能效果只有 1.5 秒。因为单机版没有服务端数据都在本地技能实现本质上就是一次额外的状态变更而已。聊天机器人 AI这个是最有趣的扩展我在每局结束前让 AI 车说一句“评价”比如“这波手速可以啊”“你是不是开了挂”。本质就是预先准备一串字符串按概率随机输出配合字符串截取和插值拼接玩家会觉得游戏有“人味儿”。如果你打算用这个项目练手我建议你按这个顺序推进先把游戏核心循环跑通能抢、能计分、能结束再做界面美化最后加扩展玩法。不要一上来就想着做技能系统或者保存历史那样很容易陷入“代码写了一堆核心玩法却很烂”的窘境。最后分享一个我自己的心得控制台版本虽然简陋但它让你把所有注意力都放在逻辑正确性和交互流畅度上不会被花哨的 UI 拖累。等你把控制台版打磨顺了再考虑迁移到 WinForms 或 WPF你会发现内核代码基本不用动只换渲染层就够了。这也正是这种小项目最有价值的地方——它逼着你写出“核心逻辑与界面分离”的代码而不是把所有东西搅成一锅粥。本文还有配套的精品资源点击获取