C#十字路口红绿灯模拟:状态机与相位控制实战

发布时间:2026/10/19 4:16:31
C#十字路口红绿灯模拟:状态机与相位控制实战
简介这是一份面向C#初学者与进阶开发者的十字路口红绿灯模拟项目源码围绕多线程、计时器控制、状态管理与WinForms界面设计展开适合用来练习事件驱动编程与线程同步等核心技能。压缩包共29个文件约1.52MB包含6个cs源码文件、4个png红绿灯素材图、2个resx资源文件、1个sln解决方案与1个csproj工程文件以及exe、pdb、cache等编译与缓存产物结构完整可直接打开运行。项目通过Timer控件周期性触发Tick事件配合TrafficLightState枚举维护红、黄、绿三态切换并用Thread或Task让四个方向独立运行借助Monitor、Mutex等同步原语避免状态冲突同时以Label或PictureBox实时刷新界面颜色。代码采用面向对象方式封装TrafficLight类兼顾异常处理与后续扩展如人行横道信号与自适应流量控制。目前已有1632人学习下载适合对照源码理解多线程调度与UI更新的完整实现思路。1. 十字路口红绿灯模拟从需求到可运行方案十字路口红绿灯模拟这个题目看着简单真动手做起来才发现坑不少。很多同学第一反应是画三个圆、按秒变色就完事结果答辩时被问“东西向和南北向的相位怎么互锁”“黄灯期间已经进入路口的车怎么处理”“行人过街请求怎么插进来”当场卡壳。这套 C# 十字路口红绿灯模拟方案要解决的就是把一个看起来像动画演示的东西做成一个有状态机、有相位约束、有配时参数、能扩展的小型仿真系统。它适合两类人一类是要交 C# 课程设计或毕设的学生需要一套结构完整、能讲清楚设计思路的代码另一类是想用 WinForms 或 WPF 快速搭一个交通信号仿真原型的开发者拿过去改配时参数就能用。整套方案围绕“相位—状态—计时”三层来组织不依赖任何第三方控件纯 C# 加 GDI 绘制就能跑起来。2. 需求拆解与技术选型为什么用状态机而不是定时器堆叠2.1 先把功能需求列清楚做这个模拟之前我一般会先把需求分成“必须做”和“可以扩展”两档避免写着写着跑偏。必须做的部分包括一个标准十字路口东西向和南北向各一组红黄绿三色灯两组灯不能同时绿必须互锁绿灯结束前要有黄灯过渡每个方向的绿灯时长、黄灯时长、全红时长可配置界面上要能实时看到当前相位和倒计时。可以扩展的部分包括行人过街按钮、左转专用相位、紧急车辆优先、车流量统计。课程设计通常做到必须做这一档就能拿不错的分数毕设可以往上加行人请求和左转相位。这里有个容易被忽略的点红绿灯不是“三个灯轮流亮”而是“相位在切换”。一个相位指的是某个方向获得通行权的状态比如“东西直行绿”是一个相位“东西黄”是另一个相位“全红”也是一个相位。把相位想清楚后面的状态机才立得住。2.2 技术选型WinForms 状态机 定时器界面层用 WinForms 就够了GDI 画圆和矩形不需要 WPF 那么重。核心逻辑用一个枚举表示相位用一个定时器驱动状态迁移。为什么不直接用多个 Timer 分别控制每个灯因为多个定时器之间没有同步关系很容易出现“东西向已经黄灯了南北向还是红灯但倒计时对不上”这种玄学问题。用一个主定时器、一个状态机所有灯的颜色都由当前相位推导出来逻辑只有一份不会打架。// 相位枚举每个值代表路口的一个通行阶段 public enum TrafficPhase { EW_Green, // 东西向绿灯南北向红灯 EW_Yellow, // 东西向黄灯南北向红灯 AllRed_EW, // 全红过渡准备切到南北向 NS_Green, // 南北向绿灯东西向红灯 NS_Yellow, // 南北向黄灯东西向红灯 AllRed_NS // 全红过渡准备切回东西向 }这段枚举是整个方案的地基。六个相位按顺序循环每个相位对应一组确定的灯色组合。注意AllRed_EW和AllRed_NS虽然灯色一样全红但它们是两个不同的相位因为下一个要切换的方向不同。如果合并成一个状态迁移时会分不清该往哪边走。2.3 配时参数怎么设计配时参数我建议单独抽一个类不要散落在代码里。典型城市路口的配时是主干道绿灯 30 秒、次干道绿灯 20 秒、黄灯 3 秒、全红 2 秒。这些值做成可配置的界面上放几个输入框就能改。public class TimingConfig { public int EWGreenSeconds { get; set; } 30; // 东西向绿灯时长 public int NSGreenSeconds { get; set; } 20; // 南北向绿灯时长 public int YellowSeconds { get; set; } 3; // 黄灯时长 public int AllRedSeconds { get; set; } 2; // 全红过渡时长 // 根据相位返回该相位的持续秒数 public int GetDuration(TrafficPhase phase) { switch (phase) { case TrafficPhase.EW_Green: return EWGreenSeconds; case TrafficPhase.EW_Yellow: return YellowSeconds; case TrafficPhase.AllRed_EW: return AllRedSeconds; case TrafficPhase.NS_Green: return NSGreenSeconds; case TrafficPhase.NS_Yellow: return YellowSeconds; case TrafficPhase.AllRed_NS: return AllRedSeconds; default: return 0; } } }GetDuration这个方法把“相位”和“时长”绑定在一起状态机每次切换相位时调一次就行。参数说明EWGreenSeconds和NSGreenSeconds是最常调的实际调试时可以把它们设成 5 秒和 3 秒方便快速看完整循环YellowSeconds一般不低于 3 秒太短了不真实AllRedSeconds是安全余量防止两个方向抢行。3. 总体架构设计三层结构与四个核心模块的职责划分3.1 三层结构怎么分整套方案分成三层表现层、逻辑层、配置层。表现层负责画灯和倒计时逻辑层负责状态迁移和相位计算配置层负责存配时参数。三层之间通过事件和属性通信表现层不直接改逻辑层的状态逻辑层也不直接操作界面控件。这样分的好处是以后要把 WinForms 换成 WPF 或者控制台逻辑层一行不用改。具体到类TrafficLightController是逻辑层的核心持有当前相位和剩余秒数MainForm是表现层订阅控制器的PhaseChanged和Tick事件来刷新界面TimingConfig是配置层被控制器引用。四个核心模块分别是状态机模块、配时模块、绘制模块、交互模块。3.2 状态机模块相位迁移的唯一入口状态机模块只做一件事根据当前相位和计时决定下一个相位是什么。迁移顺序是固定的环EW_Green → EW_Yellow → AllRed_EW → NS_Green → NS_Yellow → AllRed_NS → EW_Green。写成代码就是一个NextPhase方法。public TrafficPhase NextPhase(TrafficPhase current) { switch (current) { case TrafficPhase.EW_Green: return TrafficPhase.EW_Yellow; case TrafficPhase.EW_Yellow: return TrafficPhase.AllRed_EW; case TrafficPhase.AllRed_EW: return TrafficPhase.NS_Green; case TrafficPhase.NS_Green: return TrafficPhase.NS_Yellow; case TrafficPhase.NS_Yellow: return TrafficPhase.AllRed_NS; case TrafficPhase.AllRed_NS: return TrafficPhase.EW_Green; default: return TrafficPhase.EW_Green; } }这个环是闭合的不会出现“迁移到不存在的相位”这种情况。如果以后要加左转相位只需要在这个环里插入新的枚举值和对应的 case其他模块不用动。这就是把迁移逻辑集中在一处的价值。3.3 配时模块与绘制模块的配合配时模块提供每个相位的持续秒数绘制模块根据当前相位决定每个灯画什么颜色。这里的关键是绘制模块不判断“现在该不该绿”它只问控制器“当前相位是什么”然后查一张灯色映射表。// 根据相位返回东西向和南北向的灯色 public (Color ew, Color ns) GetLightColors(TrafficPhase phase) { switch (phase) { case TrafficPhase.EW_Green: return (Color.Green, Color.Red); case TrafficPhase.EW_Yellow: return (Color.Yellow, Color.Red); case TrafficPhase.AllRed_EW: return (Color.Red, Color.Red); case TrafficPhase.NS_Green: return (Color.Red, Color.Green); case TrafficPhase.NS_Yellow: return (Color.Red, Color.Yellow); case TrafficPhase.AllRed_NS: return (Color.Red, Color.Red); default: return (Color.Red, Color.Red); } }这张映射表把“相位”翻译成“两组灯的颜色”绘制模块拿到颜色直接画圆就行。注意全红相位下两组都是红色这是符合实际的——全红期间所有方向都停给路口清空留时间。3.4 交互模块启动、暂停、参数修改交互模块负责按钮和输入框的响应。启动按钮让主定时器开始跑暂停按钮停掉定时器但保留当前相位和剩余秒数参数修改在暂停状态下生效。这里有个细节如果运行中直接改绿灯时长当前相位的剩余秒数要不要跟着变我的做法是运行中不允许改参数必须暂停后才能改避免出现“剩余秒数比新时长还大”的尴尬情况。4. 核心模块实现定时器驱动、倒计时与界面刷新4.1 主定时器怎么驱动状态迁移主定时器用System.Windows.Forms.Timer间隔设 1000 毫秒也就是一秒一跳。每次 Tick 先把剩余秒数减一减到零就切到下一个相位并把剩余秒数重置为新相位的时长。private void MainTimer_Tick(object sender, EventArgs e) { _remainingSeconds--; // 每秒递减 if (_remainingSeconds 0) { // 当前相位结束迁移到下一个相位 _currentPhase _controller.NextPhase(_currentPhase); _remainingSeconds _config.GetDuration(_currentPhase); PhaseChanged?.Invoke(this, _currentPhase); // 通知界面刷新 } Tick?.Invoke(this, _remainingSeconds); // 通知界面更新倒计时 }这段代码里_remainingSeconds是私有字段外部只能通过事件拿到值。PhaseChanged和Tick两个事件分别对应“相位变了”和“秒数变了”两种情况界面按需订阅。参数说明定时器间隔必须是 1000 毫秒改成 500 会让倒计时跳得快一倍_remainingSeconds初始值在启动时设为第一个相位的时长。4.2 倒计时显示与灯色刷新界面上的倒计时用 Label 显示灯色用 Panel 的 Paint 事件画。每次 Tick 事件触发时更新 Label 的 Text每次 PhaseChanged 触发时重画所有灯。private void OnTick(object sender, int remaining) { lblCountdown.Text remaining.ToString() s; } private void OnPhaseChanged(object sender, TrafficPhase phase) { var (ew, ns) _controller.GetLightColors(phase); _ewLightColor ew; _nsLightColor ns; panelEW.Invalidate(); // 触发重绘 panelNS.Invalidate(); lblPhase.Text phase.ToString(); // 显示当前相位名方便调试 }Invalidate()是 WinForms 里触发重绘的标准做法不要直接调Paint方法。lblPhase显示相位名在开发阶段很有用答辩时也可以留着评委一眼就能看出当前处于哪个阶段。4.3 绘制灯的具体做法每个方向的灯用三个圆表示红黄绿画在 Panel 上。根据当前灯色决定哪个圆亮、哪个圆暗。亮的圆用饱和色暗的圆用深灰色。private void panelEW_Paint(object sender, PaintEventArgs e) { var g e.Graphics; g.SmoothingMode SmoothingMode.AntiAlias; // 抗锯齿圆更平滑 // 三个灯的位置红在上、黄在中、绿在下 DrawLight(g, 20, 20, _ewLightColor Color.Red); DrawLight(g, 20, 70, _ewLightColor Color.Yellow); DrawLight(g, 20, 120, _ewLightColor Color.Green); } private void DrawLight(Graphics g, int x, int y, bool isOn) { // 亮灯用饱和色灭灯用深灰 Color c isOn ? GetOnColor(x, y) : Color.FromArgb(60, 60, 60); using (var brush new SolidBrush(c)) { g.FillEllipse(brush, x, y, 40, 40); } }SmoothingMode.AntiAlias这行别省不加的话圆边缘会有锯齿答辩投影出来很难看。DrawLight的第三个参数是布尔值表示这个灯当前是否点亮调用方根据_ewLightColor判断。4.4 参数配置界面怎么接配置界面放四个 NumericUpDown 控件分别对应东西绿灯、南北绿灯、黄灯、全红。暂停状态下改这些值直接写回TimingConfig对象。启动时控制器从TimingConfig读时长所以改完再启动就生效。private void btnApply_Click(object sender, EventArgs e) { if (_isRunning) { MessageBox.Show(请先暂停再修改配时参数); return; } _config.EWGreenSeconds (int)numEWGreen.Value; _config.NSGreenSeconds (int)numNSGreen.Value; _config.YellowSeconds (int)numYellow.Value; _config.AllRedSeconds (int)numAllRed.Value; }这个“运行中禁止改参数”的约束看着不起眼但能避免很多边界问题。我见过有同学允许运行中改时长结果剩余秒数变成负数倒计时显示 -1s答辩时被追问了半天。5. 避坑与常见问题排查那些答辩时容易被问住的点5.1 两组灯同时绿了现象界面上东西向和南北向的绿灯同时亮着。原因通常是绘制模块各自判断灯色没有统一从相位推导。比如东西向的绘制代码里写“绿灯时长到了就变红”南北向写“红灯时长到了就变绿”两个判断独立跑中间没有互锁。解决办法是回到 3.3 节的映射表所有灯色只由GetLightColors(phase)一个入口决定绘制模块不做任何时间判断。5.2 倒计时和实际灯色对不上现象倒计时显示还有 5 秒但绿灯已经变黄了。原因是倒计时和相位迁移用了两个不同的计时源。比如倒计时用了一个 Timer相位迁移用了另一个 Timer两者启动时间差了几百毫秒跑一会儿就错位。解决办法是只用一个主定时器倒计时和相位迁移都在同一个 Tick 里处理先减秒数再判断是否切换。5.3 黄灯一闪而过或者卡住不变现象黄灯只亮了不到一秒就切走或者一直停在黄灯。前者的原因是黄灯时长配成了 0 或负数GetDuration返回 0Tick 里_remainingSeconds 0立刻成立就切走了。后者是黄灯时长配得特别大比如 300 秒看起来就像卡住了。解决办法是在配置界面加范围校验黄灯时长限制在 1 到 10 秒之间。5.4 暂停后恢复倒计时跳变现象暂停时倒计时停在 8 秒恢复后直接跳到 3 秒。原因是暂停时定时器停了但_remainingSeconds还在被别的地方改或者恢复时重新算了剩余时间。解决办法是暂停时只停定时器不动_remainingSeconds恢复时直接重启定时器从暂停时的秒数继续减。5.5 界面闪烁严重现象每次重绘时灯的区域闪一下。原因是 Panel 的默认绘制没有开双缓冲。解决办法是把 Panel 替换成自定义控件在构造函数里设DoubleBuffered true或者用SetStyle(ControlStyles.OptimizedDoubleBuffer, true)。这个坑在答辩演示时特别明显投影仪上一闪一闪的很掉分。6. 验收与答辩怎么证明这套模拟是“对的”验收这套红绿灯模拟核心不是看界面好不好看而是看逻辑对不对。我一般会准备一张验收表逐项过一遍。第一项完整跑一个循环确认六个相位按顺序出现没有跳相位或卡相位。第二项把东西绿灯设成 5 秒、南北绿灯设成 3 秒、黄灯 2 秒、全红 1 秒跑三轮确认每轮总时长一致。第三项运行中尝试改参数确认被拦截。第四项暂停 10 秒再恢复确认倒计时从暂停处继续。第五项把窗口拉大拉小确认灯的位置和大小跟着变但不变形。验收项操作预期结果相位顺序启动后观察 60 秒六相位循环无跳跃配时准确设短时长跑三轮每轮总时长误差不超过 1 秒参数保护运行中改参数弹出提示参数不变暂停恢复暂停 10 秒后恢复倒计时从暂停值继续窗口缩放拖动窗口边缘灯位置自适应圆形不变椭圆答辩时最常被问的三个问题是“你怎么保证两组灯不会同时绿”“黄灯的作用是什么”“如果要加左转相位你改哪里”第一个问题答“所有灯色由相位映射表统一决定映射表里不存在两组同时绿的组合”。第二个问题答“黄灯是清空路口的时间已经进入路口的车在黄灯期间完成通行”。第三个问题答“在相位枚举里加左转相位在 NextPhase 的环里插入在 GetLightColors 里加映射其他模块不动”。这三个问题答顺了基本就稳了。从那以后我每次做这类状态机项目都强制先把相位枚举和迁移环画在纸上确认闭合了再写代码。这个习惯帮我省了至少两次返工。希望这套方案能帮到你代码结构拿过去改配时参数就能跑扩展行人请求也只需要在环里插新相位。本文还有配套的精品资源点击获取