简介:这份C#十字路口红绿灯模拟资源面向具备C#基础、希望深入理解多线程与事件驱动编程的开发者与在校学生,通过一个完整的Windows Forms项目解决红绿灯定时切换、多方向独立运行与状态同步等典型问题。压缩包共29个文件,约1.52MB,以cs源码、png信号灯图片、resx资源文件、exe可执行程序及sln解决方案为主,另含pdb调试符号、manifest清单与settings配置,结构完整可直接编译运行。项目围绕Timer控件定时触发、TrafficLightState枚举状态管理、Thread或Task多线程调度、Label与PictureBox界面刷新展开,并涉及Monitor、Mutex等线程同步原语与异常处理机制,同时以TrafficLight类封装状态与行为,体现面向对象设计思路。目前已有1632人学习下载,适合作为课程设计、多线程练习或交通信号控制入门案例,帮助读者掌握状态切换逻辑、线程安全更新与UI联动,并为进一步扩展人行横道信号与自适应流量控制预留模块化空间。
1. 十字路口红绿灯模拟:一个把状态机讲明白的 C# 桌面项目
如果你写过 WinForms 或 WPF 的交通灯 Demo,大概率经历过这种翻车:灯色切换靠一堆if-else硬怼,倒计时和灯色各跑各的,黄灯还没闪完绿灯就亮了。这个 C# 十字路口红绿灯模拟项目,就是冲着这类问题来的——它把东西向、南北向两组信号灯放进同一个调度循环里,用状态机管灯色,用定时器管倒计时,还带上了左转相位和行人过街的简化逻辑。适合谁?正在学 C# 事件与定时器、想找一个能跑起来又能改的桌面小项目练手的人;也适合需要给教学演示做一套可配置信号配时方案的开发者。它不追求仿真精度,但把「相位切换」这件事的骨架搭得很清楚,二次开发的空间比想象中大。
2. 项目结构与技术选型:为什么用 WinForms + Timer 而不是游戏引擎
2.1 技术栈拆解
这个项目是典型的 C# 桌面应用,没有引入任何第三方 UI 框架或游戏引擎。核心依赖只有 .NET 基础类库里的System.Windows.Forms和System.Timers(或System.Windows.Forms.Timer,取决于实现版本)。选 WinForms 而不是 WPF,原因很直接:十字路口红绿灯的界面就是几个圆形色块加倒计时数字,WinForms 的Panel配合Graphics画圆足够用,不需要 XAML 那套数据绑定和样式系统。选 Timer 而不是Task.Delay循环,是因为灯色切换需要和 UI 线程保持同步——用Task.Delay在后台线程改控件属性,迟早会遇到跨线程访问异常,这是 WinForms 开发里最常见的坑之一。
常见做法是:主调度用一个System.Windows.Forms.Timer,间隔设为 1000 毫秒,每 Tick 一次就推进一秒倒计时;黄灯闪烁则另开一个间隔 500 毫秒的 Timer,或者在同一 Tick 里根据剩余秒数判断奇偶来决定显隐。我一般会把两个 Timer 都放在主窗体类里,用字段持有引用,方便在窗体关闭时统一Stop()和Dispose(),避免定时器在后台空转导致进程退不干净。
2.2 目录结构
项目正文没有给出完整目录树,但按这个场景下合格开发最可能采用的组织方式,结构大致如下:
TrafficLightSim/ ├── TrafficLightSim.sln ├── TrafficLightSim/ │ ├── Program.cs // 入口,Application.Run │ ├── MainForm.cs // 主窗体,承载绘制与调度 │ ├── MainForm.Designer.cs // 控件布局(设计器生成) │ ├── Models/ │ │ ├── LightState.cs // 灯色枚举:Red/Yellow/Green/LeftTurn │ │ ├── Phase.cs // 相位定义:方向 + 灯色 + 持续秒数 │ │ └── Intersection.cs // 路口模型,持有两组信号灯 │ ├── Services/ │ │ └── SignalController.cs // 状态机核心,负责相位推进 │ └── Config/ │ └── PhaseConfig.json // 信号配时方案,可外部修改这个结构把「数据模型」「调度逻辑」「界面」分开了。Models里只放枚举和纯数据类,不引用任何 UI 类型;SignalController只依赖Models,可以在控制台里单独测试;MainForm负责把SignalController的状态画出来。这样拆的好处是,你想换一套配时方案,改PhaseConfig.json就行;想换界面风格,动MainForm的绘制代码,不会碰到状态机。
2.3 信号配时方案的数据结构
相位配置是整个项目的输入。一个十字路口至少有四个基本相位:东西直行绿、东西左转绿、南北直行绿、南北左转绿,每个相位后面跟一个黄灯过渡。用 JSON 表达大概是这样:
{ "phases": [ { "direction": "EastWest", "light": "Green", "duration": 20 }, { "direction": "EastWest", "light": "Yellow", "duration": 3 }, { "direction": "EastWest", "light": "LeftTurn", "duration": 10 }, { "direction": "EastWest", "light": "Yellow", "duration": 3 }, { "direction": "NorthSouth", "light": "Green", "duration": 20 }, { "direction": "NorthSouth", "light": "Yellow", "duration": 3 }, { "direction": "NorthSouth", "light": "LeftTurn", "duration": 10 }, { "direction": "NorthSouth", "light": "Yellow", "duration": 3 } ] }direction表示放行方向,light是灯色,duration是持续秒数。状态机按数组顺序循环推进,每走完一个相位就切到下一个。这种「扁平相位列表」的写法比嵌套的「方向 → 灯色」结构更好扩展——你想加一个「全红清空」相位,直接在数组里插一条{ "direction": "All", "light": "Red", "duration": 2 }就行,状态机不需要改。
注意:
duration的单位是秒,但 Timer 的 Tick 间隔不一定是 1000 毫秒。如果为了演示效果把 Tick 调成 200 毫秒,倒计时逻辑要相应换算,否则会出现「显示 20 秒实际 4 秒就跑完」的玄学问题。
3. 核心模块拆解:状态机怎么驱动灯色和倒计时
3.1 灯色枚举与相位模型
先看最基础的两个类型。LightState用枚举表示灯色,Phase用类表示一个相位:
// Models/LightState.cs public enum LightState { Red, // 红灯 Yellow, // 黄灯 Green, // 绿灯直行 LeftTurn // 左转绿灯(箭头灯) } // Models/Phase.cs public class Phase { public string Direction { get; set; } // "EastWest" / "NorthSouth" / "All" public LightState Light { get; set; } // 当前相位灯色 public int Duration { get; set; } // 持续秒数 }LightState里没有单独列「左转黄灯」,因为左转相位的黄灯过渡可以复用Yellow,由Direction决定是哪个方向的黄灯。这样枚举不会膨胀。Phase是纯数据类,不包含任何行为,状态机读它、改它,但不在它里面写逻辑。
3.2 SignalController:状态机的推进逻辑
SignalController是整个项目的心脏。它持有相位列表、当前相位索引、当前相位剩余秒数,对外暴露一个Tick()方法,由 Timer 每秒调用一次:
// Services/SignalController.cs public class SignalController { private readonly List<Phase> _phases; private int _currentIndex; private int _remainingSeconds; public Phase CurrentPhase => _phases[_currentIndex]; public int RemainingSeconds => _remainingSeconds; // 相位切换时触发,参数为切换后的新相位 public event Action<Phase> PhaseChanged; public SignalController(List<Phase> phases) { _phases = phases; _currentIndex = 0; _remainingSeconds = _phases[0].Duration; } public void Tick() { _remainingSeconds--; if (_remainingSeconds <= 0) { // 推进到下一相位,循环回绕 _currentIndex = (_currentIndex + 1) % _phases.Count; _remainingSeconds = CurrentPhase.Duration; PhaseChanged?.Invoke(CurrentPhase); } } }逐段说明:_phases是构造时注入的相位列表,顺序就是执行顺序。_currentIndex指向当前相位,_remainingSeconds是当前相位还剩几秒。Tick()每次把剩余秒数减一,减到零就切下一个相位,用取模运算实现循环。PhaseChanged事件让 UI 层订阅,相位一变就刷新界面,状态机本身不碰任何控件。
参数方面,Tick()没有入参,调用频率由外部 Timer 决定。如果你想让倒计时支持小数秒,可以把_remainingSeconds改成double,Tick 时减去 Tick 间隔的秒数,但那样PhaseChanged的触发时机就要用<= 0判断,并且要注意浮点误差累积——我一般会在减到小于 0.05 时直接归零,避免出现「永远差 0.01 秒」的后悔药问题。
3.3 MainForm:把状态画到界面上
主窗体负责三件事:初始化SignalController、启动 Timer、在PhaseChanged和每次 Tick 时刷新绘制。绘制部分用Panel的Paint事件,根据当前相位决定哪个灯亮:
// MainForm.cs(节选) private SignalController _controller; private System.Windows.Forms.Timer _timer; private void MainForm_Load(object sender, EventArgs e) { var phases = PhaseConfigLoader.Load("Config/PhaseConfig.json"); _controller = new SignalController(phases); _controller.PhaseChanged += OnPhaseChanged; _timer = new System.Windows.Forms.Timer { Interval = 1000 }; _timer.Tick += (s, ev) => { _controller.Tick(); UpdateCountdownLabel(); // 刷新倒计时数字 Invalidate(); // 触发重绘 }; _timer.Start(); } private void OnPhaseChanged(Phase newPhase) { // 相位切换时可以在这里做日志或声音提示 // 注意:此事件在 UI 线程触发,可直接操作控件 lblCurrentPhase.Text = $"{newPhase.Direction} - {newPhase.Light}"; }MainForm_Load里先加载配置、构造状态机、订阅事件,再创建 Timer。Timer 的 Tick 回调里做三件事:推进状态机、更新倒计时标签、调用Invalidate()请求重绘。Invalidate()不立即重绘,而是标记区域无效,等消息循环空闲时触发Paint,这样一秒钟一次的重绘不会卡界面。
OnPhaseChanged里可以直接操作控件,因为System.Windows.Forms.Timer的 Tick 在 UI 线程执行,PhaseChanged也是在 Tick 里同步触发的。如果你换成System.Timers.Timer,回调就在线程池线程上,操作控件会抛跨线程异常,必须用Invoke包一层——这是选 Timer 类型时最容易踩的坑。
3.4 绘制逻辑:一个灯位怎么画
绘制部分集中在Paint事件里。以东西向信号灯为例,根据CurrentPhase.Direction和CurrentPhase.Light决定三个圆哪个亮:
private void pnlEastWest_Paint(object sender, PaintEventArgs e) { var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; // 三个灯位:红、黄、绿,从上到下 var lights = new[] { LightState.Red, LightState.Yellow, LightState.Green }; for (int i = 0; i < lights.Length; i++) { var rect = new Rectangle(10, 10 + i * 60, 50, 50); bool isActive = _controller.CurrentPhase.Direction == "EastWest" && _controller.CurrentPhase.Light == lights[i]; // 亮灯用饱和色,灭灯用暗色 var color = isActive ? GetActiveColor(lights[i]) : Color.FromArgb(40, 40, 40); using (var brush = new SolidBrush(color)) { g.FillEllipse(brush, rect); } } }lights数组定义了灯位的物理顺序。isActive判断当前相位是否属于这个方向且灯色匹配。亮灯用饱和色,灭灯用暗灰,视觉上区分明显。SmoothingMode.AntiAlias让圆形边缘不锯齿。每个灯位画一个 50×50 的圆,间距 60 像素,纵向排列。
这段代码里_controller.CurrentPhase被直接读取,没有加锁。因为绘制和 Tick 都在 UI 线程,不存在竞态。但如果以后把状态机移到后台线程,这里就要考虑加锁或快照——又是一个「现在没事、以后翻车」的隐患点。
4. 避坑与常见问题:定时器、线程和配置加载
4.1 倒计时和灯色不同步
现象:界面上倒计时显示还有 5 秒,但绿灯已经灭了,或者倒计时归零后灯色要等一两秒才切。
原因:倒计时标签的刷新和灯色切换不在同一个 Tick 里,或者用了两个独立的 Timer,间隔不一致导致累积误差。
解决:把倒计时刷新和状态机推进放在同一个 Tick 回调里,先_controller.Tick(),再根据_controller.RemainingSeconds更新标签,最后Invalidate()。不要用两个 Timer 分别管倒计时和灯色。
4.2 窗体关闭后进程不退出
现象:点了关闭按钮,窗体消失了,但任务管理器里进程还在。
原因:Timer 没有Stop()和Dispose(),它在后台继续 Tick,持有窗体引用,导致 GC 无法回收。
解决:在FormClosing事件里显式停止并释放 Timer:
private void MainForm_FormClosing(object sender, FormClosingEventArgs e) { _timer?.Stop(); _timer?.Dispose(); }如果用了System.Timers.Timer,还要把AutoReset设为false或手动Stop(),否则它会在后台线程一直跑。
4.3 配置文件路径找不到
现象:调试时能加载Config/PhaseConfig.json,发布后双击 exe 就报文件不存在。
原因:调试时工作目录是bin/Debug,发布后工作目录可能变成 exe 所在目录,相对路径解析结果不同。
解决:用AppDomain.CurrentDomain.BaseDirectory拼接路径,或者把配置文件设为「复制到输出目录:始终复制」,然后用Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Config", "PhaseConfig.json")读取。不要用Directory.GetCurrentDirectory(),它的行为在不同启动方式下不一致。
4.4 黄灯闪烁频率不对
现象:黄灯要么不闪,要么闪得飞快。
原因:闪烁逻辑写在了 1000 毫秒的 Tick 里,用_remainingSeconds % 2 == 0判断显隐,但黄灯只有 3 秒,实际只闪了一次半。
解决:黄灯闪烁单独用一个 500 毫秒的 Timer,或者在 1000 毫秒 Tick 里用_remainingSeconds的奇偶控制,但要把黄灯持续时间设为偶数秒。更稳妥的做法是在Phase里加一个Blink布尔字段,状态机在黄灯相位时通知 UI 启动闪烁 Timer,相位结束再停掉。
4.5 左转相位和直行相位冲突
现象:东西左转绿灯亮的时候,南北直行也亮着绿灯,两股车流交叉。
原因:相位列表里没有把冲突方向排成互斥,状态机只是顺序执行,不检查方向冲突。
解决:在配置层面保证同一时刻只有一个方向放行。如果要在代码里加校验,可以在SignalController构造时遍历相位列表,检查相邻相位之间是否存在「两个方向同时为 Green 或 LeftTurn」的情况,有就抛异常。这是配置错误,不是状态机 bug,早发现早改。
5. 二次开发:从固定配时到自适应配时的改造思路
这个项目目前的配时方案是静态的——相位顺序和持续时间都写在 JSON 里,跑起来就按这个循环。但真实路口的信号配时是要根据车流量动态调整的。如果你想把它往「自适应配时」方向改,可以从三个层面入手。
第一层是数据输入。在SignalController里加一个UpdateDuration(string direction, int newDuration)方法,允许外部在运行时修改某个方向的绿灯时长。然后加一个模拟车流量的模块——可以是一个随机数生成器,每隔几个周期给东西向和南北向各生成一个「排队长度」,根据排队长度比例调整下一周期的绿灯时长。排队长的方向多给几秒,排队短的少给几秒,总周期保持不变。
第二层是相位跳过。如果某个方向连续几个周期都没有车,可以在状态机推进时跳过它的绿灯相位,直接进黄灯或者全红。实现方式是在Tick()里判断_remainingSeconds <= 0之后,不直接取下一个相位,而是调用一个GetNextPhase()方法,在这个方法里根据当前各方向的「需求值」决定下一个该放行谁。这样状态机就从「顺序循环」变成了「按需调度」。
第三层是配时方案的持久化。把每次调整后的相位时长写回 JSON 或写到一个单独的runtime_config.json,下次启动时优先读运行时配置。这样你调了一下午的参数不会因为关掉程序就丢了。写回的时候注意用File.WriteAllText加临时文件再替换的方式,避免写一半程序崩溃导致配置文件损坏。
验证改造效果的方法也很直接:在界面上加一个「统计」面板,显示每个方向最近 N 个周期的平均等待时间。如果自适应算法有效,高峰方向的平均等待时间应该下降,非高峰方向不会明显恶化。我一般会先跑固定配时记录一组基线数据,再切到自适应跑同样长的时间,对比两组数字。没有基线数据的优化都是凭感觉,而感觉在信号配时这件事上经常不靠谱。
从那以后我每次改状态机逻辑,都强制先把SignalController单独拎出来写一个控制台测试,用Tick()手动推进几百次,打印每个相位的起止时间,确认没有跳相位、没有负倒计时、没有死循环,再接回 UI。这个习惯帮我省掉了至少三次「界面上看着对、实际相位已经乱了」的排查时间。希望帮到你。
本文还有配套的精品资源,点击获取