首页 / 资讯中心 / 文章详情

C# WinForms 十字路口红绿灯模拟:状态机驱动与车辆队列实战

C# WinForms 十字路口红绿灯模拟:状态机驱动与车辆队列实战 ★ FEATURED ARTICLE
简介这份C#十字路口红绿灯模拟资源面向具备C#基础、希望深入理解多线程与事件驱动编程的开发者尤其适合课程设计或自学练手场景。资源包共29个文件约1.52MB以cs源码文件为核心配合png红绿灯图片素材、resx与resources资源文件、sln与csproj工程配置以及exe可执行程序和pdb调试文件构成一个可直接运行的Windows Forms项目。项目围绕Timer控件定时切换、TrafficLightState枚举状态管理、多线程独立控制四个方向信号灯、UI控件实时刷新颜色等知识点展开并涉及Monitor、Mutex等线程同步原语与异常处理机制。代码采用面向对象方式封装TrafficLight类模块化设计便于后续扩展人行横道信号或自适应流量控制。目前已有1632人学习下载读者可借此掌握红绿灯状态转换逻辑、线程安全更新UI的排错思路并对照完整工程结构快速上手调试与二次开发。1. 十字路口红绿灯模拟一个把状态机讲明白的 C# 桌面项目如果你写过 WinForms 或 WPF 的交通灯 Demo大概率经历过这样的场景四个方向的红绿灯各自为政倒计时跳着跳着就撞在一起两个方向同时亮绿灯界面上看起来像在演车祸现场。这个 C# 十字路口红绿灯模拟项目解决的就是这类状态同步问题。它用一套集中式状态机驱动东西、南北两个方向的红灯、绿灯、黄灯按固定时序轮转配合倒计时显示和车辆等待队列把路口调度逻辑完整跑通。适合刚学完 C# 基础语法、想找一个能看得见摸得着的练手项目的开发者也适合需要给教学演示做一套可交互红绿灯模型的场景。整个项目不依赖数据库和网络双击就能跑改起来也直观。2. 项目结构与技术选型为什么用 WinForms 加状态机2.1 技术栈拆解与选型理由这个项目选的是 C# .NET Framework或 .NET 6/8 的 Windows 桌面目标 WinForms。有人会问为什么不用 WPF 或 MAUI答案很实际红绿灯模拟的核心是定时刷新和图形绘制WinForms 的Timer组件加Panel或PictureBox自绘已经足够启动快、依赖少、代码量可控。对于教学演示和二次开发入门来说少一层 XAML 绑定就少一层理解成本。核心依赖只有三个System.Windows.Forms承载窗口、按钮、绘图面板System.Drawing画圆、画灯杆、画倒计时文字System.Timers.Timer或System.Windows.Forms.Timer驱动状态切换常见做法是把灯的状态定义成枚举把每个方向的灯组封装成一个类再由一个调度器统一推进。这样做的直接好处是以后要加左转灯、人行道灯只需要扩展枚举和调度逻辑不用把界面代码翻个底朝天。2.2 目录结构长什么样一个干净的项目目录通常是这样组织的TrafficLightSim/ ├── Program.cs // 入口启动主窗体 ├── MainForm.cs // 主窗体逻辑绑定按钮和绘图 ├── MainForm.Designer.cs // 设计器生成的控件布局 ├── Core/ │ ├── LightState.cs // 灯状态枚举 │ ├── DirectionLight.cs // 单个方向的灯组模型 │ └── TrafficController.cs// 调度器核心状态机 ├── Render/ │ └── LightRenderer.cs // 绘制灯和倒计时 └── Resources/ └── (图标等可选资源)Core目录放纯逻辑不引用任何 WinForms 类型方便单元测试Render目录只负责把状态画出来MainForm做粘合层。这个分层不是过度设计而是因为红绿灯逻辑一旦和控件事件缠在一起后面想改配时方案就会非常痛苦。2.3 状态机模型四个方向怎么不打架十字路口本质是两相位或四相位控制。这个项目采用最经典的两相位模型东西方向放行时南北方向红灯南北方向放行时东西方向红灯。每个相位内部再分绿灯、黄灯、红灯三个子状态。用枚举表示public enum LightState { Red, // 红灯 Green, // 绿灯 Yellow // 黄灯 }调度器维护一个当前相位和当前状态定时器每 100ms tick 一次累计时间到达阈值就切换。关键点是所有方向的灯状态由同一个调度器计算界面只读取结果。这样就不可能出现两个方向同时绿灯的玄学问题。public class TrafficController { private int _elapsedMs; private int _greenDuration 5000; // 绿灯5秒 private int _yellowDuration 2000; // 黄灯2秒 private int _redDuration 7000; // 红灯7秒对向绿灯黄灯 public LightState EastWestState { get; private set; } public LightState NorthSouthState { get; private set; } public void Tick(int deltaMs) { _elapsedMs deltaMs; // 根据累计时间判断当前处于哪个阶段 // 具体切换逻辑见下一章 } }参数说明_greenDuration、_yellowDuration、_redDuration是配时参数改这三个值就能调整路口通行效率。Tick方法接收时间增量而不是自己读系统时间是为了让逻辑可测试——单元测试里可以手动喂时间不用真的等 5 秒。3. 核心模块拆解从定时器到界面刷新的完整链路3.1 状态切换逻辑一个周期怎么走完状态机的推进逻辑是整个项目的心脏。两相位模型的完整周期是东西绿 → 东西黄 → 南北绿 → 南北黄 → 回到东西绿。红灯不需要单独计时因为它是对向绿灯加黄灯的时长。public void Tick(int deltaMs) { _elapsedMs deltaMs; // 计算当前处于周期中的哪个位置 int cycle _greenDuration _yellowDuration; int phaseTime _elapsedMs % (cycle * 2); if (phaseTime _greenDuration) { // 第一阶段东西绿灯南北红灯 EastWestState LightState.Green; NorthSouthState LightState.Red; RemainingMs _greenDuration - phaseTime; } else if (phaseTime cycle) { // 第一阶段末尾东西黄灯南北红灯 EastWestState LightState.Yellow; NorthSouthState LightState.Red; RemainingMs cycle - phaseTime; } else if (phaseTime cycle _greenDuration) { // 第二阶段南北绿灯东西红灯 EastWestState LightState.Red; NorthSouthState LightState.Green; RemainingMs cycle _greenDuration - phaseTime; } else { // 第二阶段末尾南北黄灯东西红灯 EastWestState LightState.Red; NorthSouthState LightState.Yellow; RemainingMs cycle * 2 - phaseTime; } }逻辑说明用取模运算把无限递增的时间映射到一个固定周期内避免用一堆if判断“现在该谁亮”。RemainingMs是当前状态剩余毫秒数界面拿它除以 1000 就是倒计时秒数。这种写法的好处是周期边界清晰改配时只需要改三个时长参数不用动分支结构。参数怎么改如果想让绿灯更长把_greenDuration从 5000 改成 8000黄灯一般不建议低于 1500否则司机反应不过来这是血泪经验。红灯时长不用单独设它由对向的绿灯加黄灯自动决定。3.2 界面刷新Timer 怎么驱动重绘WinForms 里刷新界面有两种常见做法一是用System.Windows.Forms.Timer在Tick事件里调用Invalidate()触发重绘二是用System.Timers.Timer但要注意跨线程问题。这个项目推荐第一种因为它在 UI 线程上执行不用Invoke少踩坑。private TrafficController _controller new TrafficController(); private System.Windows.Forms.Timer _timer; private void InitTimer() { _timer new System.Windows.Forms.Timer(); _timer.Interval 100; // 100ms刷新一次兼顾流畅和性能 _timer.Tick (s, e) { _controller.Tick(_timer.Interval); _panel.Invalidate(); // 触发Panel重绘 }; _timer.Start(); }逻辑说明Interval 100意味着每秒 tick 10 次倒计时显示按秒跳但状态切换的精度是 100ms不会出现“显示还剩1秒但灯已经变了”的错位。_panel.Invalidate()把重绘请求交给消息循环OnPaint里再统一画。参数说明Interval不建议低于 50否则 CPU 占用会明显上升也不建议高于 200否则倒计时看起来会卡顿。100 是一个经过验证的平衡点。3.3 绘制逻辑灯和倒计时怎么画绘制放在Panel的Paint事件里用Graphics对象画圆和文字。每个方向画三个灯位根据状态决定填充色亮灯加光晕灭灯用暗色。private void Panel_Paint(object sender, PaintEventArgs e) { Graphics g e.Graphics; g.SmoothingMode SmoothingMode.AntiAlias; // 画东西方向灯组 DrawLightGroup(g, new Point(50, 50), _controller.EastWestState); // 画南北方向灯组 DrawLightGroup(g, new Point(250, 50), _controller.NorthSouthState); // 画倒计时 string countdown (_controller.RemainingMs / 1000 1).ToString(); g.DrawString(countdown, this.Font, Brushes.White, new PointF(150, 200)); } private void DrawLightGroup(Graphics g, Point origin, LightState state) { Color red state LightState.Red ? Color.Red : Color.FromArgb(60, 0, 0); Color yellow state LightState.Yellow ? Color.Yellow : Color.FromArgb(60, 60, 0); Color green state LightState.Green ? Color.LimeGreen : Color.FromArgb(0, 60, 0); g.FillEllipse(new SolidBrush(red), origin.X, origin.Y, 40, 40); g.FillEllipse(new SolidBrush(yellow), origin.X, origin.Y 50, 40, 40); g.FillEllipse(new SolidBrush(green), origin.X, origin.Y 100, 40, 40); }逻辑说明亮灯用饱和色灭灯用低亮度同色系视觉上能看出“灯位存在但没亮”。倒计时用RemainingMs / 1000 1是为了让显示从 5 开始而不是 4符合直觉。SmoothingMode.AntiAlias让圆边缘不锯齿这是 WinForms 绘图里必加的一行。参数说明origin控制灯组位置改坐标就能调整布局圆的大小 40 是经验值太小看不清太大占地方。如果要加左转箭头灯在DrawLightGroup里加一个画箭头的分支即可。3.4 车辆等待队列让模拟更像真的纯灯切换看起来还是有点干加一个简单的车辆等待队列会让演示效果提升一个档次。思路是每个方向维护一个等待车辆数绿灯时按固定速率放行红灯时按到达率累加。public class DirectionQueue { public int WaitingCount { get; private set; } private double _arrivalRate 0.5; // 每tick到达0.5辆 public void OnTick(int deltaMs, bool isGreen) { // 红灯时车辆到达 if (!isGreen) { _arrivalAccumulator _arrivalRate * deltaMs / 1000.0; while (_arrivalAccumulator 1) { WaitingCount; _arrivalAccumulator - 1; } } else { // 绿灯时放行每200ms走一辆 _departAccumulator deltaMs; while (_departAccumulator 200 WaitingCount 0) { WaitingCount--; _departAccumulator - 200; } } } }逻辑说明到达用累加器实现小数速率避免“每 tick 加 0.5 辆”变成整数截断。放行速率 200ms 一辆是模拟车辆启动延迟。这个队列不参与灯状态决策只做展示所以不会影响核心状态机的稳定性。参数说明_arrivalRate调大路口更拥堵调小更通畅放行间隔 200 可以改成 150 让通行更快。这两个参数适合在界面上做成滑块二次开发时可以加。4. 避坑与常见问题那些让我加班到深夜的细节4.1 倒计时显示跳变或卡住现象倒计时从 5 直接跳到 3或者停在某个数字不动。原因RemainingMs计算时用了整数除法或者 Timer 的Interval和状态切换阈值不是整数倍关系导致某一帧剩余时间算出来是负数或零。解决在Tick里对RemainingMs做Math.Max(0, ...)保护显示时用(RemainingMs 999) / 1000向上取整保证最后一秒显示 1 而不是 0。另外确认 Timer 没有在切换状态时被重复启动。4.2 两个方向同时绿灯现象东西和南北的绿灯同时亮起界面上看起来像撞车。原因状态切换写成了两个独立的if分支没有用else if串联导致一个 tick 内两个方向都被赋了绿灯。解决所有状态赋值必须在一个互斥的分支结构里完成像 3.1 那样用if / else if / else覆盖完整周期。永远不要在两个地方分别设置两个方向的状态。4.3 界面刷新闪烁严重现象灯和倒计时每次重绘都闪一下看久了眼睛疼。原因Panel没有开启双缓冲每次Invalidate都先擦背景再画产生闪烁。解决自定义一个继承自Panel的类在构造函数里设置DoubleBuffered true和SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true)。或者直接在窗体级别开双缓冲。这是 WinForms 绘图的老毛病加一行就好。4.4 改配时参数后逻辑错乱现象把绿灯时长从 5 秒改成 8 秒后黄灯不亮了或者红灯时长不对。原因红灯时长是硬编码的没有跟着绿灯变化。或者周期计算里用了旧的常量。解决红灯时长不要单独存用_greenDuration _yellowDuration动态算。所有时长参数集中在一个配置类里改的时候只改一处。我一般会加一个Validate()方法在启动时检查黄灯是否大于 0、绿灯是否大于黄灯不满足就抛异常。4.5 车辆队列数字变成负数现象等待车辆数显示为 -1 或 -3。原因放行逻辑里先减后判断WaitingCount在 0 的时候还被减。解决放行循环里加WaitingCount 0条件像 3.4 那样。另外到达和放行用两个独立的累加器不要共用一个变量否则会互相干扰。5. 二次开发与改造从能跑到好用5.1 加一个配时配置界面现在改配时要动代码二次开发第一步可以加一个设置面板用NumericUpDown控件让用户直接调绿灯、黄灯时长。核心是把TrafficController里的字段改成属性并在 setter 里做范围校验。public int GreenDuration { get _greenDuration; set { if (value 1000 || value 60000) throw new ArgumentOutOfRangeException(绿灯时长应在1-60秒之间); _greenDuration value; } }然后在窗体上加三个NumericUpDown绑定到这些属性。注意改配时后要重置_elapsedMs否则当前周期会按旧参数算完出现一次异常跳变。5.2 支持四相位和左转灯两相位改四相位本质是把状态机的分支从 4 个扩展到 8 个或 12 个。建议不要继续堆if / else而是定义一个Phase类每个相位包含哪些方向绿灯、哪些方向红灯、持续时长。调度器只负责按顺序遍历相位列表。public class Phase { public LightState EastWest { get; set; } public LightState NorthSouth { get; set; } public LightState LeftTurn { get; set; } public int DurationMs { get; set; } }这样加左转灯只需要在列表里插入一个相位不用改调度逻辑。这是从“能跑”到“好改”的关键一步。5.3 用单元测试锁住状态机状态机最容易在改参数时被改坏建议给TrafficController写几个单元测试。因为Tick接收时间增量测试里可以一次性喂 5000ms断言状态是否正确。[TestMethod] public void Tick_AfterGreenDuration_ShouldTurnYellow() { var controller new TrafficController(); controller.GreenDuration 5000; controller.Tick(5000); Assert.AreEqual(LightState.Yellow, controller.EastWestState); Assert.AreEqual(LightState.Red, controller.NorthSouthState); }测试覆盖四个阶段边界和周期回绕以后不管怎么调配时跑一遍测试就知道有没有改坏。这是我踩过坑之后养成的习惯状态机没有测试改参数就是开盲盒。5.4 导出运行日志辅助排查如果要做更复杂的模拟建议加一个简单的日志每次状态切换时记录时间戳和状态。用Liststring存内存里界面上加一个按钮导出到 txt。排查“为什么这个周期黄灯少了一次”这类问题时日志比盯着界面看靠谱得多。private void LogStateChange(string direction, LightState oldState, LightState newState) { _logs.Add(${DateTime.Now:HH:mm:ss.fff} {direction}: {oldState} - {newState}); }日志不用多只在状态变化时写一行跑一小时也就几百条翻起来很快。从那以后我每次改状态机参数都强制先跑一遍单元测试再开界面看效果省下来的调试时间够多写两个功能。希望这个项目的拆解能帮到你拿到源码后先跑起来再从配时参数改起慢慢就能摸清整套调度逻辑。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站