简介:这是一份面向C#初学者与游戏开发爱好者的单机版抢车位游戏源码,采用面向对象编程思想设计,通过Player、Car、ParkingSpace等类的交互模拟停车位争夺的策略玩法,适合用来练习C#语法、封装继承多态等OOP核心概念,也可作为课程设计或实战练手项目。资源包共83个文件,约6.37MB,以37个png与9个jpg界面素材、15个cs源码文件为主,另含resx与resources资源文件、exe可执行程序、pdb调试文件及sln、csproj工程文件,并附有界面功能说明文档与玩家对象初始化文本,代码可直接查看和修改。目前已有242人学习下载。读者可从中获得完整的游戏逻辑实现、界面交互设计思路,以及线程同步、计时器等模拟真实停车环境的技术参考,还能按需扩展新功能或调整游戏规则,加深对C#桌面应用开发流程的理解。
1. 单机抢车位游戏:一个被低估的 C# 状态机练手项目
很多人第一次听到「单机版抢车位游戏」这个词,脑子里浮现的是当年社交平台上那种定时收菜、贴条、占位的休闲页游。但如果你是一个 C# 开发者,这个标题真正的价值不在玩法本身,而在于它是一道极好的综合练习题:它逼着你把定时器、状态机、资源竞争、数据持久化、UI 刷新这几件事在一个没有网络、没有服务端的封闭环境里全部串起来。单机意味着你不需要考虑并发连接和服务器同步,但也意味着所有逻辑都得自己扛——车位什么时候刷新、玩家什么时候能抢、抢到之后怎么计费、离线之后数据怎么存,这些全是本地状态管理问题。它适合已经会写 C# 基础语法、但还没独立做过一个完整小系统的人,也适合想拿一个轻量项目练手面向对象设计的老手。接下来我会按「先想清楚状态怎么流转、再动手把最小可跑版本搭出来、最后处理那些真正会让你翻车的边界」这条线,把整个方案讲透。
2. 抢车位游戏的状态模型与核心对象设计
2.1 为什么先画状态机再写代码
抢车位这类游戏,表面上是「点一下按钮,车停进去」,但真正决定代码质量的,是车位本身的状态流转。一个车位在任意时刻只可能处于几种状态之一:空闲、被占用、锁定中(正在被某个玩家抢)、冷却中(刚被贴条或刚被释放)。如果你不先把这几个状态和它们之间的迁移条件定清楚,后面写出来的代码一定是一堆 if-else 堆在一起,改一个逻辑就崩三个地方。
我一般会先用一个枚举把状态定死,再给每个状态写清楚「什么事件能触发它迁移到哪个状态」。常见做法是画一张状态迁移表,不用工具,直接在纸上列出来就行。比如空闲状态收到「玩家点击抢占」事件后,先进入锁定中,锁定中如果校验通过就进入被占用,校验失败就退回空闲。被占用状态收到「计时到期」事件后进入冷却中,冷却中再收到「冷却结束」事件回到空闲。这张表定下来之后,代码结构基本就出来了。
这里有一个容易被忽略的点:单机游戏里没有真正的并发,但如果你用了多线程定时器去刷新车位状态,就相当于人为制造了并发场景。所以状态迁移的入口最好收敛到一个方法里,所有状态变更都走它,方便加锁和打日志。这是后面排查玄学问题的后悔药。
2.2 核心类与职责划分
把状态想清楚之后,接下来是类的划分。我一般会拆成四个核心类:ParkingSpot(车位)、Player(玩家)、GameClock(游戏时钟)、GameManager(总控)。ParkingSpot 持有自己的状态、占用者、占用开始时间、冷却结束时间;Player 持有金币、已占车位列表、历史记录;GameClock 负责推进游戏内时间并触发定时事件;GameManager 负责接收玩家操作、调用校验逻辑、驱动状态迁移。
这样拆的好处是每个类的职责单一,测试的时候可以单独构造一个 ParkingSpot 来验证状态迁移,不用把整个游戏跑起来。下面是一个最小化的 ParkingSpot 定义,你可以直接抄进项目里改:
public enum SpotState { Idle, // 空闲,可被抢占 Locking, // 锁定中,正在校验抢占请求 Occupied, // 已被占用,计时中 Cooling // 冷却中,暂时不可抢占 } public class ParkingSpot { public int Id { get; set; } public SpotState State { get; private set; } = SpotState.Idle; public string OwnerId { get; private set; } public DateTime OccupiedAt { get; private set; } public DateTime CoolingUntil { get; private set; } // 所有状态变更的唯一入口,方便加锁和日志 public bool TryTransition(SpotState target, string operatorId = null) { // 这里写状态迁移合法性校验,非法迁移直接返回 false if (!IsTransitionAllowed(State, target)) return false; State = target; if (target == SpotState.Occupied) { OwnerId = operatorId; OccupiedAt = DateTime.Now; } if (target == SpotState.Cooling) { CoolingUntil = DateTime.Now.AddSeconds(30); // 冷却 30 秒,可配置 } return true; } private bool IsTransitionAllowed(SpotState from, SpotState to) { // 只允许相邻状态迁移,禁止跳跃 return (from, to) switch { (SpotState.Idle, SpotState.Locking) => true, (SpotState.Locking, SpotState.Occupied) => true, (SpotState.Locking, SpotState.Idle) => true, (SpotState.Occupied, SpotState.Cooling) => true, (SpotState.Cooling, SpotState.Idle) => true, _ => false }; } }这段代码里最关键的是 TryTransition 这个唯一入口。参数 target 是目标状态,operatorId 是操作者标识,只在迁移到被占用状态时用到。IsTransitionAllowed 用 C# 8 的模式匹配写迁移表,清晰且不容易漏。冷却时间我写成 30 秒常量,实际项目里应该抽到配置类里,方便调参。注意这里没有加锁,因为单机版如果只在 UI 线程操作,其实不需要锁;但如果你用 System.Timers.Timer 在后台线程刷新状态,就必须在 TryTransition 里加 lock,否则会出现状态被改了一半的诡异现象。
2.3 游戏时钟与定时刷新
单机抢车位游戏的时间推进有两种做法:一种是真实时间,用系统时钟,玩家离线了时间也在走;另一种是游戏内时间,只有游戏运行时才推进。我建议用真实时间做基础,但把「离线期间发生的事」在启动时一次性结算。这样既符合玩家直觉,又不会让离线玩家吃亏太多。
GameClock 的职责是每隔一个 tick 检查所有车位:被占用的车位是否到期、冷却中的车位是否冷却结束。tick 间隔我一般设 1 秒,太短浪费 CPU,太长玩家感觉卡顿。下面是一个简化的时钟实现:
public class GameClock { private readonly List<ParkingSpot> _spots; private readonly System.Timers.Timer _timer; public event Action<ParkingSpot> SpotFreed; // 车位释放事件,UI 订阅刷新 public GameClock(List<ParkingSpot> spots, double tickSeconds = 1.0) { _spots = spots; _timer = new System.Timers.Timer(tickSeconds * 1000); _timer.Elapsed += OnTick; _timer.AutoReset = true; } public void Start() => _timer.Start(); public void Stop() => _timer.Stop(); private void OnTick(object sender, System.Timers.ElapsedEventArgs e) { var now = DateTime.Now; foreach (var spot in _spots) { // 占用到期:进入冷却 if (spot.State == SpotState.Occupied && (now - spot.OccupiedAt).TotalSeconds >= 60) // 占用 60 秒,可配置 { spot.TryTransition(SpotState.Cooling); } // 冷却结束:回到空闲 if (spot.State == SpotState.Cooling && now >= spot.CoolingUntil) { spot.TryTransition(SpotState.Idle); SpotFreed?.Invoke(spot); // 通知 UI } } } }这里 tickSeconds 默认 1 秒,占用时长和冷却时长都写成硬编码常量,实际项目应该放到一个 GameConfig 静态类里。SpotFreed 事件是给 UI 层订阅的,这样时钟逻辑和界面刷新解耦。注意 OnTick 是在后台线程执行的,如果你在事件处理里直接操作 WinForms 或 WPF 控件,会抛跨线程异常。常见做法是在事件订阅方用 Control.Invoke 或 Dispatcher.Invoke 切回 UI 线程。这个坑我后面还会专门讲。
3. 从零搭出可运行的最小版本
3.1 项目结构与依赖选择
单机版抢车位游戏,我建议用 WinForms 或 WPF 做界面,因为这两个是 C# 桌面开发最成熟的选择,资料多、踩坑少。如果你更熟悉控制台,也可以先用控制台把逻辑跑通,再套界面。项目结构上,我一般分三个文件夹:Models(放 ParkingSpot、Player 等数据类)、Logic(放 GameManager、GameClock)、UI(放窗体代码)。这样分层之后,逻辑代码可以单独写单元测试,不用启动界面。
依赖方面,单机版不需要数据库也能跑,用 JSON 文件存盘就够了。C# 里序列化 JSON 最省事的是 System.Text.Json,.NET Core 3.0 以后内置,不用装 NuGet 包。如果你用的是 .NET Framework 4.x,那就用 Newtonsoft.Json,NuGet 一装就能用。存盘文件放在程序目录下的 save.json,每次操作后异步写一次,避免频繁 IO 卡界面。
3.2 抢占车位的完整交互流程
玩家点击一个空闲车位,到车位变成被占用,中间要经过校验、锁定、写入、刷新四步。校验包括:玩家金币是否足够、车位是否真的空闲、玩家是否已有车位在占用(有些玩法限制同时只能占一个)。锁定是为了防止快速连点导致重复占用。写入是把占用信息记到车位和玩家两边。刷新是通知 UI 更新显示。
下面这段是 GameManager 里的抢占方法,你可以直接参考:
public class GameManager { private readonly List<ParkingSpot> _spots; private readonly Player _player; private readonly object _lock = new object(); public GameManager(List<ParkingSpot> spots, Player player) { _spots = spots; _player = player; } public bool TryOccupy(int spotId) { lock (_lock) // 防止定时器线程和 UI 线程同时改状态 { var spot = _spots.FirstOrDefault(s => s.Id == spotId); if (spot == null) return false; // 校验:必须空闲,且玩家金币足够 if (spot.State != SpotState.Idle) return false; if (_player.Coins < 10) return false; // 占用一次花 10 金币,可配置 // 先锁定,再占用,两步走防止中间被其他逻辑插入 if (!spot.TryTransition(SpotState.Locking)) return false; if (!spot.TryTransition(SpotState.Occupied, _player.Id)) { spot.TryTransition(SpotState.Idle); // 回滚 return false; } _player.Coins -= 10; _player.OccupiedSpots.Add(spot.Id); return true; } } }lock 这里锁的是 _lock 对象,不是 this,也不是 _spots,这是为了避免外部代码锁同一个对象导致死锁。校验顺序是先判空、再判状态、再判金币,任何一步失败都直接返回,不做多余操作。锁定再占用的两步走是关键,因为 TryTransition 内部有合法性校验,直接 Idle 到 Occupied 会被拒绝。回滚逻辑也要写,否则锁定之后占用失败,车位就卡在 Locking 状态永远出不来了。金币扣减放在状态变更成功之后,保证不会出现扣了钱没占到位的翻车情况。
3.3 存盘与读盘的最小实现
单机游戏最怕的就是玩到一半关掉,再打开数据没了。存盘逻辑我一般做成「操作后标记脏数据,定时器每 5 秒检查一次,脏了就写」。这样既不会频繁 IO,也不会丢太多进度。读盘在程序启动时执行,如果文件不存在就初始化一份默认数据。
public static class SaveSystem { private static readonly string SavePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "save.json"); public static void Save(GameState state) { var json = JsonSerializer.Serialize(state, new JsonSerializerOptions { WriteIndented = true // 方便调试时直接看文件内容 }); File.WriteAllText(SavePath, json); } public static GameState Load() { if (!File.Exists(SavePath)) return GameState.CreateDefault(); var json = File.ReadAllText(SavePath); return JsonSerializer.Deserialize<GameState>(json) ?? GameState.CreateDefault(); } }GameState 是一个聚合类,包含所有车位、玩家、最后保存时间。WriteIndented 设为 true 是为了调试方便,正式发布可以关掉减小体积。Load 里做了空值兜底,反序列化失败或文件为空时返回默认状态,避免启动就崩。注意 File.WriteAllText 是同步的,如果存档很大(一般不会),可以换成异步版本。另外存盘路径用 AppDomain.CurrentDomain.BaseDirectory 而不是当前工作目录,避免从不同位置启动程序时存档跑到别的地方去。
4. 避坑与常见问题排查
4.1 跨线程更新 UI 导致程序闪退
现象:游戏运行几秒后突然崩溃,异常信息是「跨线程操作无效,从不是创建控件的线程访问它」。原因:GameClock 用的 System.Timers.Timer 在后台线程触发 Elapsed 事件,你在事件处理里直接改了 Label 的 Text 或 Button 的 Enabled。解决:所有 UI 更新都切回 UI 线程。WinForms 用 control.Invoke(new Action(() => { ... })),WPF 用 Dispatcher.Invoke。更省事的做法是时钟只负责改数据,UI 用一个单独的 UI 定时器(System.Windows.Forms.Timer)每秒读一次数据刷新,这样天然在 UI 线程。
4.2 状态卡在 Locking 出不来
现象:某个车位点了一次之后一直显示「锁定中」,再也抢不了。原因:TryOccupy 里锁定成功但占用失败时,回滚逻辑没写或者写错了,车位停在 Locking 状态。解决:在 TryTransition 里加超时检查,Locking 状态超过 2 秒自动回退到 Idle。或者在 GameClock 的 tick 里统一扫描所有 Locking 车位,超时强制回退。血泪经验是,任何中间状态都必须有超时兜底,否则一定会出现卡死。
4.3 存档文件损坏导致启动崩溃
现象:程序启动直接闪退,日志显示 JSON 反序列化异常。原因:上次写盘时程序被强制结束,文件只写了一半。解决:写盘用「先写临时文件,再替换正式文件」的方式,保证原子性。读盘时用 try-catch 包住反序列化,失败就备份损坏文件并加载默认状态。这个坑在单机项目里特别常见,因为玩家关程序往往直接点右上角叉,不给保存的机会。
4.4 定时器叠加导致时间加速
现象:游戏运行一段时间后,车位刷新速度越来越快。原因:每次开始新一局都 new 了一个 GameClock,但旧的 Timer 没 Stop,多个定时器同时在跑。解决:GameClock 做成单例,或者在重新开始前显式调用 Stop 并置空。我一般会在 GameManager 里持有唯一的 GameClock 实例,生命周期和游戏进程一致,不随局面重置而重建。
4.5 金币扣减与状态变更不同步
现象:玩家金币扣了,但车位没占到,或者车位占到了金币没扣。原因:扣金币和改状态分在两处,中间抛异常或提前 return。解决:把扣减和状态变更放在同一个 lock 块里,并且状态变更成功后再扣。如果状态变更失败,直接 return,不碰金币。更严格的做法是用一个事务性的方法把两步包起来,任何一步失败都回滚。
5. 进阶技巧:用配置驱动让玩法可调
最小版本跑通之后,你会发现所有数值都硬编码在代码里,改一个占用时长就要重新编译,这在实际维护中很痛苦。我一般会做一个 GameConfig 类,把所有可调参数集中起来,从 JSON 文件读取,这样改玩法不用动代码。下面是一个配置类的骨架:
public class GameConfig { public int OccupyCost { get; set; } = 10; // 占用花费 public int OccupySeconds { get; set; } = 60; // 占用时长 public int CoolingSeconds { get; set; } = 30; // 冷却时长 public int MaxSpotsPerPlayer { get; set; } = 3; // 每人最多占几个 public double TickSeconds { get; set; } = 1.0; // 时钟精度 public static GameConfig Load(string path = "config.json") { if (!File.Exists(path)) return new GameConfig(); var json = File.ReadAllText(path); return JsonSerializer.Deserialize<GameConfig>(json) ?? new GameConfig(); } }这个类里每个属性都给了默认值,配置文件缺失或字段缺失时不会崩。Load 方法用可选参数给默认路径,调用方不传也能跑。实际使用时,GameManager 和 GameClock 都从 GameConfig 实例里读参数,而不是用常量。这样你想调快游戏节奏,改 JSON 里的 OccupySeconds 就行,不用重新编译。
配置化之后,验证玩法是否合理就方便多了。我一般会写一个简单的控制台测试:构造 10 个车位、1 个玩家、1000 金币,模拟连续抢占 100 次,看最终状态是否一致、有没有车位卡死、金币总数对不对。这种压力测试能提前暴露状态迁移的边界问题,比手动点界面靠谱得多。
还有一个进阶方向是加一个简单的 AI 对手,让单机版也有对抗感。AI 的逻辑可以很简单:每隔几秒随机选一个空闲车位尝试抢占,金币不够就等待。这样你就能观察两个玩家抢同一个车位时状态机是否扛得住。我自己的习惯是,任何状态机项目,写完核心逻辑后一定先写一个随机操作的压测脚本跑一万次,确认没有非法状态迁移,再去做界面。这个习惯帮我省了无数次调试界面的时间。
希望帮到你。
本文还有配套的精品资源,点击获取