
简介基于C#语言开发的单机版抢车位游戏完整源码面向C#初学者、面向对象编程学习者及游戏开发入门者。以抢车位玩法为载体完整演示了从玩家、车辆、车位等类设计到用户登录、窗体交互、游戏循环的开发链路适合课程设计、个人练手或毕业设计参考。压缩包共83个文件大小6.37MB主要包含15个cs源码文件核心逻辑与窗体、37个png和9个jpg界面素材、3个resx与3个resources资源文件另有exe可执行程序、功能说明doc文档和初始化玩家对象的txt代码目录清晰便于运行和修改。已有244人学习浏览。源码开放可直接查看和修改包含Player、Car、ParkingSpace、LoginFrm、ManiFrm等核心类模拟随机事件、时间限制与策略决策并涉及线程同步、计时器等机制配套文档详细说明了界面布局、按钮功能和操作流程初始化示例展示了创建玩家对象、分配初始资源的完整步骤对理解封装、继承、多态以及多窗体程序组织方式都有直接帮助也能根据需求扩展车位类型或调整游戏规则非常适合作为C#实战练习项目。1. 单机版抢车位把社交玩法压进 C# 的一个完整闭环抢车位这个玩法经历过社交平台时代的人都不陌生把车停到别人车位赚钱趁对方离线时贴条、拖车最刺激的就是收益上限卡点前被人清空车位。放在当年这套规则几乎全在服务端本地只负责发请求想把它改成一个离线可玩、逻辑完整、拿得出手的 C# 项目得把停车、贴条、拖车、AI 对手整个重写一遍。这份资源做的就是这件事它不是教学 Demo而是一套能直接编译运行、完整玩完一把对局的课程设计级源码。适合正在找 C# 题目交差的学生也适合想从增删改查跳出来、认真摸一遍游戏状态机写法的初学者。2. 先拆玩法再拆代码抢车位的核心规则与类设计2.1 抢车位到底在抢什么贴条与拖车的收益闭环先说玩法因为类设计完全由玩法推导出来。经典抢车位的循环是这样地图上有若干公共车位玩家的车停进去后按分钟产出金币收益有上限停得越久越接近上限。但车位是有限的别人的车占着位置你就可以对他做两件事贴条没收他当前累计收益的一半作为罚金拖车直接清空累计收益并把车送回空闲状态进入一段冷却时间。这两个动作构成了整个游戏的博弈核心车位越热门、停得越久越容易被针对。单机版最大的问题在于没有真人来“恶心”你。如果没有AI这个游戏本质上就是个倒计时领钱器五分钟就没劲了。所以这套资源里实现了AI玩家来承担对抗角色它会有三种行为基元寻找空位停车、给玩家或者其他AI的车贴条、拖别人的车。各行为触发概率由难度参数控制后面第4章单独讲。先把规则定下来所有的代码逻辑都以这张表为准。动作目标条件效果罚金公式停车车位空闲车辆不在冷却车位被占开始累计收益无贴条车位被占且未被贴条没收当前累计收益标记贴条AccIncome × 50% 20拖车车位被占且未被贴条清空累计收益车辆进冷却AccIncome × 80%挪车玩家同一辆车停到新空位旧车位释放累计收益清零无这张表是整套源码的基石后续所有方法都围绕它展开。注意“挪车”这条容易被忽略经典规则里把车从 A 车位挪到 B 车位A 车位释放但累计收益清零。这么设计是为了防刷钱——如果挪车不清零玩家可以把车停在高级车位上赚满上限再挪到另一个高级车位继续赚等于无限套利。拿到源码后改功能时这条规则建议不要动一动经济系统就崩。2.2 六张核心类图从 Car 到 GameData 的职责划分接下来看资源里的类设计。它没有用复杂的框架就是几个实体类加一个游戏管理类。实体类全部采用“贫血模型”只存数据不写逻辑规则统一放在 GameManager 里。为什么这么分因为停车、贴条、拖车之间交互频繁贴条要改车位状态、改车辆收益、改玩家金币如果规则散落在实体类里后期调整 AI 策略时会改到怀疑人生。集中管理后规则的入口只有一个调试时下断点也只盯 GameManager 一个类。核心实体类的定义如下命名空间统一放在 ParkingGame.Models 下public class Car { public int Id { get; set; } public string Nickname { get; set; } // 车牌昵称玩家自定义 public int Level { get; set; } // 等级影响收益加成 public int AccIncome { get; set; } // 当前累计未结算收益 public int CurrentSpaceId { get; set; } // 当前所在车位Id-1表示闲置 public bool InCooldown { get; set; } // 拖车后的冷却标志 public DateTime CooldownEnd { get; set; } // 冷却结束时间 } public class ParkingSpace { public int Id { get; set; } public int Row { get; set; } public int Col { get; set; } public int Level { get; set; } // 车位等级影响基础收益倍率 public int ParkedCarId { get; set; } // 停着的车Id-1表示空闲 public bool HasTicket { get; set; } // 是否被贴条 public DateTime ParkedAt { get; set; } // 停车时间用于收益上限计算 } public class Player { public string Name { get; set; } public int Money { get; set; } public ListCar Cars { get; set; } } public class GameData { public Player HumanPlayer { get; set; } public ListAIPlayer AIPlayers { get; set; } public ListParkingSpace Spaces { get; set; } public int TickSec { get; set; } // 每个Tick对应的游戏秒数 public bool IsRunning { get; set; } }这段代码有几个细节值得说。Car 里的 CurrentSpaceId 用 -1 表示闲置而不是用int?原因有两个一是 JSON 序列化时 -1 不需要额外处理存档读档更省事二是判断“这辆车现在有没有停在车位上”时写成car.CurrentSpaceId -1一眼就能看懂不会出现 null 判断漏掉的情况。ParkingSpace 里的 ParkedAt 用的是 DateTime 而不是 tick 计数这是为了存档后不丢收益进度——如果你存的是“停了 300 个 tick”读档后 Timer 重启tick 数就对不上了DateTime 则能通过相对时间差恢复。AIPlayer 在资源里单独定义它继承 Player额外带一组决策权重字段public class AIPlayer : Player { public int ParkWeight { get; set; } // 停车行为权重 public int TicketWeight { get; set; } // 贴条行为权重 public int TowWeight { get; set; } // 拖车行为权重 public int ThinkTick { get; set; } // 每几Tick决策一次 }这组权重会在第 4 章详细展开。现在只要知道每个 AI 不是每 Tick 都行动而是每隔 ThinkTick 才做一次决策这样能模拟“思考延迟”也能避免 AI 行动过于频繁导致玩家毫无还手之力。想调出不同性格的 AI就是改这四个数字后续不用动任何逻辑代码。2.3 游戏主循环Timer 驱动的秒级收益结算机制整个游戏的调度核心是一个 System.Windows.Forms.Timer。为什么不用 System.Timers.Timer因为 WinForms 的 Timer 事件在 UI 线程上触发事件里可以直接改控件属性不需要 Invoke 跨线程System.Timers.Timer 的回调在线程池线程上改界面必须 this.Invoke写起来麻烦还容易死锁。单机游戏的主循环没有高性能需求Forms.Timer 的毫秒级精度完全够用。初始化代码长这样private void InitGameLoop() { gameTimer new Timer(); gameTimer.Interval 200; // 200ms 一次Tick即每秒5次 gameTimer.Tick GameTimer_Tick; gameTimer.Start(); gameData.TickSec 3; // 每个Tick相当于游戏内3秒 tickCounter 0; } private void GameTimer_Tick(object sender, EventArgs e) { if (!gameData.IsRunning) return; tickCounter; gameManager.UpdateIncome(gameData); // 第1步所有在停车辆结算收益 gameManager.UpdateCooldown(gameData); // 第2步检测冷却结束恢复可用 if (tickCounter % aiDecisionInterval 0) // 第3步AI按节奏做决策 { aiController.Decide(gameData); } if (tickCounter % uiRefreshInterval 0) // 第4步定时刷新界面不每Tick刷 { RenderBoard(); } }这里有个容易抄错的地方Interval 为什么用 200 而不是 1000因为收益结算如果每秒才走一次玩家看到的数字变化是跳变没有“金币在涨”的持续感反过来如果每毫秒结算一次CPU 浪费严重。200ms 一个 Tick每 5 个 Tick 凑成游戏内 3 秒需要改游戏速度时只改 TickSecTimer 本身不用动。参数上aiDecisionInterval 默认设 6意思是 AI 每 6 个 Tick、约 1.2 秒决策一次uiRefreshInterval 默认设 10即约 2 秒刷新一次界面。UI 刷新频率别设太高WinForms 里每次改 PictureBox 的 BackColor 都会触发一次重绘48 个车位全刷一遍就容易闪烁这个玄学问题第 5 章再展开。3. 把停车逻辑写成代码车位状态机与收益结算3.1 车位的四种状态与状态迁移条件车位的生命周期可以抽象成四种状态空闲、占用未贴条、占用已贴条、冷却中。严格说冷却属于车辆状态但它的存在影响了车位的可停判定所以实际编码时两个状态要一起判断。把这四种状态画成迁移图逻辑是这样的空闲状态下玩家或 AI 可以停车进入占用占用状态下可以被贴条进入已贴条占用和已贴条状态下都可以被拖车拖完后车位回到空闲车辆进入冷却冷却结束车辆重新可用。看起来简单但“贴条之后能不能拖”这个细节很多初版实现都会做错资源里明确要求贴条后的车不能再被拖避免双重惩罚叠加。核心方法全部收敛在 GameManager 里三个方法对应三个玩家动作public bool TryPark(GameData data, int carId, int spaceId) { var car FindCar(data, carId); var space data.Spaces[spaceId]; if (space.ParkedCarId ! -1) { return false; // 车位被占不能停 } if (car.InCooldown) { return false; // 被拖后冷却中 } if (car.CurrentSpaceId ! -1) { // 自己已有车停在别处停到新位子时先把旧车收回来 var oldSpace data.Spaces[car.CurrentSpaceId]; oldSpace.ParkedCarId -1; oldSpace.HasTicket false; car.AccIncome 0; } space.ParkedCarId carId; space.ParkedAt DateTime.Now; car.CurrentSpaceId spaceId; return true; }注意 TryPark 的返回值是 bool这是给 UI 层用的。玩家点了空车位但停车失败时UI 层要根据返回值决定弹提示还是静默处理如果方法返回 voidUI 层就只能靠猜。另外移动旧车时把 AccIncome 清零并且把旧车位的 HasTicket 也重置这两步缺一不可。漏掉 HasTicket 重置的话旧车位会残留“已贴条”标记后续 AI 决策时以为那里有车可贴直接空指针或者收益错乱。接下来是贴条和拖车public bool TryTicket(GameData data, int spaceId) { var space data.Spaces[spaceId]; if (space.ParkedCarId -1 || space.HasTicket) { return false; // 没车可贴或者已经贴过 } var car FindCar(data, space.ParkedCarId); var player FindOwner(data, car.Id); int fine (int)(car.AccIncome * 0.5) 20; // 罚金收入一半加20保底 player.Money - Math.Min(player.Money, fine); // 收益不够就从本金扣 car.AccIncome 0; space.HasTicket true; return true; } public bool TryTow(GameData data, int spaceId) { var space data.Spaces[spaceId]; if (space.ParkedCarId -1 || space.HasTicket) { return false; // 拖车只对未贴条的在停车辆生效 } var car FindCar(data, space.ParkedCarId); var player FindOwner(data, car.Id); int fine (int)(car.AccIncome * 0.8); player.Money - Math.Min(player.Money, fine); car.AccIncome 0; car.CurrentSpaceId -1; car.InCooldown true; car.CooldownEnd DateTime.Now.AddSeconds(config.TowCooldownSec); space.ParkedCarId -1; space.HasTicket false; return true; }两个方法的公共判断都用条件表达式合在一行这是故意的贴条和拖车的目标条件高度相似写进方法的开头一眼就能看出差异只在 HasTicket 的判断。罚金里Math.Min(player.Money, fine)的处理要说明一下——如果玩家的当前金币小于罚金只扣到 0不让玩家负债。课程设计答辩时经常会被问到“罚金不够扣怎么办”这个实现就是标准答案。3.2 点击命中检测用控件数组而不是算像素坐标点击车位做交互是初版实现翻车率最高的地方。常见的做法是给地图 Panel 挂一个 MouseClick 事件然后用 e.Location 除以格子宽高算行列。听起来很合理但实际跑起来经常点一个格子响应的是旁边那个因为 Panel 有边框、有 Padding、有 AutoScrollPosition甚至窗体缩放后格子尺寸变了你硬编码的固定行列全部失效。这个资源里的做法非常务实用 TableLayoutPanel 铺满 PictureBox 矩阵每个 PictureBox 对应一个 ParkingSpace点击事件挂在格子上Tag 里存车位索引。这样甚至不需要写坐标换算鼠标点的是哪个 PictureBox就必然是哪一个车位。// 初始化 6 行 8 列的网格映射到 GameData.Spaces for (int r 0; r rows; r) { for (int c 0; c cols; c) { var index r * cols c; var pic new PictureBox(); pic.Width cellW; pic.Height cellH; pic.Tag index; // Tag 存车位索引不存对象引用 pic.BackColor GetSpaceColor(data.Spaces[index]); pic.Click SpaceCell_Click; tableLayout.Controls.Add(pic, c, r); } } private void SpaceCell_Click(object sender, EventArgs e) { var pic (PictureBox)sender; int spaceIndex (int)pic.Tag; if (currentAction PlayerAction.Park) gameManager.TryPark(gameData, selectedCarId, spaceIndex); else if (currentAction PlayerAction.Ticket) gameManager.TryTicket(gameData, spaceIndex); else if (currentAction PlayerAction.Tow) gameManager.TryTow(gameData, spaceIndex); RenderBoard(); // 操作后立即刷新不等下一个UI刷新周期 }cellW 和 cellH 不要在初始化时写死应该在 Form 的 Resize 事件里重新计算cellW (tableLayout.ClientSize.Width - tableLayout.Padding.Horizontal) / cols; cellH (tableLayout.ClientSize.Height - tableLayout.Padding.Vertical) / rows;Tag 里存索引而不是直接存 ParkingSpace 对象这算是一个防御性习惯如果后续在代码里替换了整个 Spaces 列表比如读档后重建索引仍然有效对象引用则会变成悬空引用。用控件数组方案之后鼠标坐标计算这个“玄学”环节完全消失了代价仅仅是内存里多了几十个 PictureBox对单机游戏来说可以忽略。3.3 收益结算上限、保底与结算顺序收益结算是整个游戏的经济引擎代码最短但最容易写歪。资源里的 UpdateIncome 方法在每次 Timer Tick 时被调用遍历所有车位给停着的车累加收益。关键有三点收益上限、保底收益、结算顺序。public void UpdateIncome(GameData data) { foreach (var space in data.Spaces) { if (space.ParkedCarId -1) continue; var car FindCar(data, space.ParkedCarId); if (car null || car.CurrentSpaceId ! space.Id) continue; // 收益上限判断停车超过 MaxStopSeconds 后不再涨钱 var parkedDuration (DateTime.Now - space.ParkedAt).TotalSeconds; if (parkedDuration config.MaxStopSeconds) { continue; } int baseIncome config.BaseIncomePerMinute; double rate 1.0 car.Level * 0.2 space.Level * 0.1; int income (int)(baseIncome * rate * data.TickSec / 60.0); car.AccIncome Math.Max(income, 1); // 保底 1 金币避免0收益 } }上限判断用 DateTime 差值而不是 tick 计数器原因在第 2 章说过存档读档时只有 DateTime 能正确恢复。rate 的计算用的是加法稀释乘法收益倍率 1 车辆等级加成 车位等级加成这样后期数值膨胀速度可控。如果你改成 1.0 * 1.2^Level * 1.1^SpaceLevel指数增长会在 30 分钟后完全失控这是数值设计上的一个陷阱。保底 1 金币必须加原因很实际TickSec 设置为 3 秒时基础收益如果是 20 金币/分钟每个 Tick 的理论收益是 1 金币如果把 TickSec 改成 1 秒理论收益算下来是 0 金币玩家看到车停了半天一分钱没涨会认为游戏坏了。Math.Max(income, 1)一行解决这个边界问题代价是极端低收益时金币总量会略高于理论值但单机游戏内部经济这点误差无所谓。结算顺序上保持“先结算收益后处理玩家操作”是这套源码的一个隐式约定。玩家在两次 UI 刷新之间点击贴条贴条方法拿到的是最新结算过的 AccIncome罚金计算是准的反过来如果先执行操作再结算贴条罚金会少算一个 Tick 的收益虽然单次误差很小但累计起来就是“为什么我总是差几块钱”的诡异问题。4. 单机版不冷场AI 对手的决策与实现4.1 三种行为与加权随机为什么 AI 决定要“抽签”单机版抢车位最怕冷场。如果 AI 只是在车位空着时去停游戏不会有任何对抗感所以资源里的 AI 集成了三种完整行为停车、贴条、拖车。但 AI 不能每次决策都做同样的动作否则行为模式很快就会被玩家摸透于是这里引入加权随机——给每个行为分配一个权重值权重越大被抽中的概率越高。这样 AI 的行为有随机性但整体倾向可控。public void Decide(GameData data) { foreach (var ai in data.AIPlayers) { if (!ai.IsFree) continue; var action WeightedPick(ai.ParkWeight, ai.TicketWeight, ai.TowWeight); switch (action) { case AiAction.Park: PerformPark(data, ai); break; case AiAction.Ticket: PerformTicket(data, ai); break; case AiAction.Tow: PerformTow(data, ai); break; case AiAction.Wait: break; // 啥也不干模拟“在考虑中” } } } private AiAction WeightedPick(int parkW, int ticketW, int towW) { int total parkW ticketW towW 5; // 最后5是Wait的基础权重 int rnd random.Next(total); if (rnd parkW) return AiAction.Park; if (rnd parkW ticketW) return AiAction.Ticket; if (rnd parkW ticketW towW) return AiAction.Tow; return AiAction.Wait; }加权随机实现的细节在区间划分。第一段区间长度是 parkW第二段是 ticketW以此类推。这里有个很隐蔽的 bug 源random必须是类级别的静态实例不能在 Decide 里new Random()。同一个 Tick 里多个 AI 依次决策如果各自 new Random系统时钟种子相同的情况下会生成完全相同的随机序列结果就是所有 AI 在同一次决策中做出一模一样的动作。这个资源里把 Random 设成了 GameManager 的静态字段踩过这个坑的人都知道我在说什么。4.2 决策前先做状态快照防止 AI 之间串台AI 决策过程中最容易出问题的不是随机数而是数据一致性。假设有 3 个 AI第一个 AI 停进了 8 号车位第二个 AI 在扫描空闲车位时也选中了 8 号——如果它直接读实时的 data.Spaces会看到 8 号已经被占然后跳过但如果它的扫描逻辑是先把所有空闲车位收集成 List再从中随机选一个而这个 List 是决策开始时生成的那它选中的可能正是第一个 AI 刚占掉的车位最终停车失败表现为“AI 莫名其妙发呆”。这个资源里的解法是给每次决策做一次数据快照所有 AI 基于同一个快照做判断而不是基于实时变化的 Live 数据// AI 决策入场前锁定一次状态避免同一轮内相互覆盖 var snapshot data.Spaces.Select(s new SpaceSnapshot { SpaceId s.Id, ParkedCarId s.ParkedCarId, HasTicket s.HasTicket }).ToArray(); foreach (var ai in data.AIPlayers) { var target ChooseTargetFromSnapshot(snapshot, ai); if (target null) continue; // 真正执行时要重新检查实时状态防止快照过期导致逻辑错乱 if (data.Spaces[target.SpaceId].ParkedCarId -1) { PerformPark(data, ai, target.SpaceId); } }SpaceSnapshot 是一个轻量结构体只包含判断所需的三个字段不引用实体对象。快照的存在让同一轮 AI 决策之间互不干扰玩起来的感觉就是“三个 AI 在同一秒各自做了自己的决定”这正是单机版对多人博弈的模拟。但也要注意快照的时效执行操作前必须回到实时数据上做一次二次校验否则 AI 的停车操作会因为快照过期而频繁失败失败率过高时 AI 看起来就像一个傻子。4.3 难度参数三个档位的调参建议资源内置了新手、普通、困难三个难度档位实际改动只有一张表参数新手普通困难ThinkTick决策间隔864ParkWeight停车权重706050TicketWeight贴条权重202530TowWeight拖车权重51015AI 收益倍率1.01.01.2调难度的逻辑有两条线。第一条是决策频率ThinkTick 越小AI 行动越频繁玩家刚停下车 2 秒就被盯上的几率越大压迫感直接上升。第二条是行为倾向新手档拖车权重只有 5AI 很少拖车玩家不会被突然清空车位困难档把拖车权重拉高到 15同时给 AI 加了 1.2 倍收益倍率保证 AI 在频繁对抗的同时自己的经济也能跟上玩家不会滚雪球崩盘。这里有个容易忽略的细节AI 收益倍率不是加在 AI 玩家的初始资金上而是加在 AI 每次收益结算的产出上。如果只调初始资金游戏进行 10 分钟后 AI 就会破产后续只能靠贴条和拖车维持行为模式会变得非常单调。倍率调产出才能让 AI 长期和玩家保持同层级的竞争力。5. 避坑记录抢车位开发中常见的五个翻车现场5.1 界面卡死Tick 事件里做了耗时操作现象游戏运行后点击按钮没有任何反应窗口拖动时出现白屏几秒后才恢复。原因Timer 事件本身在 UI 线程执行如果 Tick 里阻塞了整条消息队列全部排队所有控件都卡死。当时是在 RenderBoard 里循环给 48 个 PictureBox 设置 BackColor 和 Text图片数量多时一次刷新耗时接近 300ms而 Timer 每隔 200ms 就触发一次等于 UI 线程永远在赶场。解决把“数据更新”和“界面渲染”彻底分层。Tick 事件里只改 GameData 数据RenderBoard 统一放在 uiRefreshInterval 控制下实测将刷新频率降到 2 秒一次并把 PictureBox 的 DoubleBuffered 属性设为 true界面卡死问题完全消失。5.2 收益翻倍重复创建 Timer 没释放旧实例现象玩着玩着金币增速突然变快打开日志发现 UpdateIncome 被执行了两次甚至三次。原因实现“重新开始游戏”功能时代码里直接 new 了一个 Timer 再 Start但没有 Stop 和 Dispose 旧 Timer。旧实例还在 Tick 事件里继续驱动 UpdateIncome新旧两个 Timer 同时在跑收益结算被重复执行。解决新开一局前强制执行一次gameTimer?.Stop(); gameTimer?.Dispose();再 new 新实例。从那以后我每次写带 Timer 的游戏都会养成习惯只要出现“重新开始”“切换场景”这类逻辑第一步永远是清理旧的定时器这个习惯帮我省了无数次排查时间。5.3 坐标偏差固定行列在窗体缩放后全部错位现象窗口默认大小时点击正常把窗口拉大后点击格子响应的是旁边甚至对角线的车位。原因初始化时把格子宽高写死成了默认窗口对应的尺寸Resize 后 PictureBox 被 TableLayoutPanel 自动拉伸但点击事件里记录的车位索引仍然按初始化时的顺序排列。其实这个方案本身没有错错的是格子尺寸没有跟着重算。解决改用控件数组方案后把 cellW 和 cellH 的计算放在 Resize 事件里每次窗体尺寸变化都重新计算。代码上只加了几行但从此坐标偏移的问题再没出现。如果坚持用坐标手动算行列还需要额外减去 Panel 的 Padding 和 Border 宽度那个组合就是玄学中的玄学。5.4 贴条与拖车同时生效AI 把玩家打到倒欠金币现象玩家车辆先被贴条紧接着又被拖走两者罚金叠加玩家金币直接变负游戏数值崩坏。原因TryTicket 和 TryTow 都只检查了车辆是否存在没有互斥检查。贴条之后车还在原车位拖车逻辑又把它判定为“可拖对象”两套罚金叠在一起扣。解决在 TryTow 开头加上if (space.HasTicket) return false;明确贴条和拖车是互斥动作。规则上贴条后的车不能被拖这样罚金只有一笔玩家损失可预期AI 的连招惩罚也不会显得“不讲道理”。5.5 读档后时间错乱DateTime 直接序列化导致收益上限失效现象昨晚存档时车才停了 5 分钟今天打开继续车子的收益直接变成 0显示已到上限。原因存档时把 ParkedAt 按 DateTime 序列化读档后 DateTime.Now 减去存档里的 ParkedAt得到的是包含整个离线时间段的差值远超收益上限系统理所当然地认为收益已经停发。解决存档时不要存 ParkedAt而是存累计停车秒数。读档后重建 ParkedAtspace.ParkedAt DateTime.Now.AddSeconds(-space.ParkedElapsedSeconds);这样离线时间不会混入收益时长。另一个坑是 DateTime 在 JSON 里默认序列化成带时区的 UTC 字符串读档回来如果不转换成当地时间差值也会错建议统一用 DateTimeOffset 或者干脆在存档类里全用 Unix 时间戳。6. 越玩越像样JSON 存档、车位升级与数值平衡技巧6.1 用 System.Text.Json 做存档注意版本兼容存档是课程设计里最容易拿加分也最容易丢分的点。资源里没有用复杂的二进制格式就是 System.Text.Json 直接序列化 GameData 对象public void SaveGame(GameData data, string filePath) { var options new JsonSerializerOptions { WriteIndented true, Converters { new JsonStringEnumConverter() } }; var json JsonSerializer.Serialize(data, data.GetType(), options); File.WriteAllText(filePath, json); } public GameData LoadGame(string filePath) { var json File.ReadAllText(filePath); var data JsonSerializer.DeserializeGameData(json); return data; }因为所有实体类都是普通对象序列化不需要额外标记。如果以后给类加了新字段建议在存档里加一个 version 字段读档时判断版本做字段补默认值避免旧存档直接反序列化报错。这个 version 字段不用参与游戏逻辑只在存档类里额外挂一个属性就行。6.2 贴条罚金别写 100%一个保底公式数值平衡是整个单机版能“玩得下去”的底线。贴条罚金如果设为当前累计收益的 100%玩家停满半小时被贴一次条直接归零挫败感强到想删游戏。资源里的处理方式是 50% 加固定保底int fine (int)(car.AccIncome * 0.5) 20;这样收益低的阶段贴条也有 20 金币保底收益AI 不会因为玩家车上的钱少而懒得贴条收益高的时候罚金不会超过收入的一半玩家的损失可接受也有动力继续经营。拖车罚金用 80% 而不是 100%也是同样的道理——拖车是最强的惩罚手段但直接清零会让玩家觉得“这个游戏不讲道理”。车位升级的数值设计也是同理每升一级花费是当前等级金币的 1.8 倍收益加成只加 10%。这个曲线保证前期升级便宜、收益提升明显后期升级贵但在玩家可负担范围内。整个升级系统给了玩家一个消耗金币的出口不然游戏进行 20 分钟后金币溢出所有惩罚都变得不痛不痒。说到车位升级顺手分享一个我自己的习惯每次调试数值平衡我都会强制走一遍“新号开局 10 分钟、挂机 1 小时、AI 困难档”三个状态的完整流程观察金币曲线和玩家挫败感。这个项目我拆完后也重跑了一遍贴条保底公式就是从那轮调试里看到的——罚金低于 20 金币档位AI 贴条热情明显下降。以后再做这类模拟经营小游戏我应该都会先拿这三档流程测试一遍再交出去。希望帮到你。本文还有配套的精品资源点击获取