简介:这份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 导出运行日志辅助排查
如果要做更复杂的模拟,建议加一个简单的日志,每次状态切换时记录时间戳和状态。用List<string>存内存里,界面上加一个按钮导出到 txt。排查“为什么这个周期黄灯少了一次”这类问题时,日志比盯着界面看靠谱得多。
private void LogStateChange(string direction, LightState oldState, LightState newState) { _logs.Add($"{DateTime.Now:HH:mm:ss.fff} {direction}: {oldState} -> {newState}"); }日志不用多,只在状态变化时写一行,跑一小时也就几百条,翻起来很快。
从那以后我每次改状态机参数,都强制先跑一遍单元测试再开界面看效果,省下来的调试时间够多写两个功能。希望这个项目的拆解能帮到你,拿到源码后先跑起来,再从配时参数改起,慢慢就能摸清整套调度逻辑。
本文还有配套的精品资源,点击获取