☰
C#控制台游戏实战:单机版抢车位设计与实现
2026/10/8 20:43:13 网站建设 项目流程

简介:单机版抢车位游戏(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 确认按键的反应时间 }

有朋友问我,为什么不用数据库存车位数据?对这种单机小项目,集合就够了。我用了一个List<ParkingSlot>,配合Dictionary<int, Vehicle>保存每辆车和车位的对应关系,这样做的原因在于:单机版游戏的生命周期只有几分钟,数据量极小,连 SQLite 都属于过度设计。等到你真要扩展成局域网联机版,再迁移数据库也不迟,游戏逻辑类和数据类边界清晰,迁移成本很低。

2.2 游戏主循环与事件驱动

控制台游戏的主循环,本质上是一个“轮询 + 状态搬运”的过程。我不建议在这种场景里用真正的事件驱动或多线程,原因后面会说。我采用的是while (true)+Thread.Sleep(50)轮询模式:

while (!gameOver) { Render(); HandleInput(); UpdateAIDrivers(); CheckCollisionAndScore(); Thread.Sleep(50); }

为什么不用多线程让每个 AI 车都跑一个独立的 Timer?因为控制台程序的输入是阻塞的,多线程很容易出现同时修改控制台光标位置的竞态问题。到时候你看到的就是屏幕上车位状态还没刷新完,玩家输入又进来了,整个界面乱成一团。轮询虽然在 CPU 利用率上不如事件驱动“优雅”,但对这种单机小游戏来说,稳定性和开发效率都更好。

50ms这个刷新间隔也是有讲究的。我试过10ms,CPU 占用偏高还感觉不到流畅度提升;试过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)截出来的是-10,int.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,你会发现内核代码基本不用动,只换渲染层就够了。这也正是这种小项目最有价值的地方——它逼着你写出“核心逻辑与界面分离”的代码,而不是把所有东西搅成一锅粥。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询