
前几天收拾代码仓库时翻到了这个项目差点忘了自己还写过这么热血的一档子事。项目代号很简单Apollo-11。名字听着唬人实际就是一台用 Python 做的复古任务控制面板模拟器核心场景一句话就能说明白——手动或自动操纵一艘虚拟飞船靠面板上的按键、数码管和状态灯完成一次软着陆。做这个项目的初衷也不复杂我一直对极简接口背后挂着一个复杂系统的设计特别着迷想亲手验证一下纯粹靠数字和状态灯能不能把一个完整的控制任务讲清楚。写完之后回头看收获远超一个界面状态机、数值积分、PID 控制这些平时熟悉又容易糊弄过去的东西全被这个项目逼着重新过了一遍。这篇记录应该能帮到三类人想搞清楚控制系统和状态机怎么落地的同学、喜欢复古终端和工业 UI 风格的开发者、以及手痒想练手但不知道从哪下手的业余玩家。代码不多逻辑也不算深但每一步都有值得抠的细节。1. 项目定位与整体设计先划清边界动手写代码之前我先把不做什么想清楚了。一个登月着陆模拟器如果往上堆东西可以加 3D 引擎、加音效、加多人在线、加任务编辑器加到最后必然烂尾。我给自己定的目标是一台普通电脑上能跑起来纯键盘操作有一个有复古质感的控制面板以及一个足够真实的物理模型。功能上砍到只剩三个手动着陆、自动着陆、任务日志回放。1.1 核心需求拆解我把整个项目拆成了四块控制面板交互数字键盘、功能键、数码管显示、状态灯这是门面。飞行物理引擎飞船的二维运动、重力、推力、燃料消耗这是骨架。任务控制逻辑待机、下降、着陆、中止等一系列状态切换这是大脑。日志与回放把每次飞行过程记录下来方便事后分析这是复盘工具。这四块之间是单向依赖的关系。UI 只负责收集按键和显示数据不关心飞船如何运动物理引擎只输出状态量不关心按钮怎么触发状态机处在中间扮演的是协调者角色根据指令切换阶段再驱动物理引擎。这个分层方式让我后面调 bug 时省了大力气某个环节出错基本能一眼定位到模块不用在杂乱代码里翻。1.2 技术选型为什么是 Python pygame选 Python 的原因很实在开发速度快调试方便不需要编译写几行代码就能跑起来验证想法。图形库选 pygame 而不是更现代的 raylib 或者 Web 方案是因为我的目标是先把逻辑跑通pygame 的事件系统和像素操作足够简单而且文档和社区示例都非常成熟。有人可能会问都什么年代了为什么不用 Web 技术复刻一个控制台我的理由是桌面单机应用可以让我完全掌控渲染循环和物理步进不受浏览器刷新机制干扰。而且后续如果想移植到嵌入式屏幕上pygame 的代码结构也可以平滑迁移过去。项目依赖很少Python 3.10 以上pygame-ce 2.3.2社区维护版性能比原版好一点建议用虚拟环境隔离别把全局 Python 搞乱。整个项目目录结构如下apollo11/ ├─ main.py # 入口与主循环 ├─ config.py # 所有常量、物理参数、布局配置 ├─ logic/ │ ├─ dynamics.py # 飞船运动学与质量消耗 │ ├─ pid_controller.py # 自动着陆 PID 控制器 │ ├─ state_machine.py # 任务状态机 │ └─ telemetry.py # 传感器数据模拟 ├─ ui/ │ ├─ panel.py # 控制面板整体渲染 │ ├─ keypad.py # 数字键盘与功能键逻辑 │ ├─ display.py # 七段数码管显示 │ └─ lamps.py # 状态灯渲染 └─ data/ └─ mission_log.json # 任务日志输出1.3 模块之间如何通信我设计了一个非常轻量的指令队列。UI 层把用户操作转换成一个个指令对象放进队列状态机每帧从队列里取指令并处理。物理引擎完全不关心指令来源只暴露一个step(dt)方法让状态机去调用。这么做有一个明显好处手动模式和自动模式可以在不改动物理引擎的前提下切换。手动模式下状态机把按键操作映射为油门和倾角指令自动模式下状态机调用 PID 控制器得到指令。物理引擎内部并不区分。这种指令驱动的模式我觉得是这个小项目里最有复用价值的设计之一。2. 控制台交互界面靠一排按键和数码管撑起操作感控制面板是整个项目气质所在。我参考的其实是那种老式工业计算机的交互方式不会有很多菜单和图标只有一排排按键、数字显示和状态灯所有信息都是编码化的。用户必须知道自己输入的指令是什么意思才能操作系统。这种不友好反而成就了一种独特的仪式感。2.1 面板布局与显示区域面板整体分成三个区域左上角是状态显示区显示当前程序号PROG、动词VERB和名词NOUN以及一组关键遥测数据。中间是七段数码管区域用橙红色字形显示高度、速度、燃料等信息。下方是键盘区包含数字 0-9、加减号、PROG、VERB、NOUN、ENTER、CLR 等功能键。我最初花了很多时间纠结如何摆放这些控件后来发现最有效的办法是先画一个 ASCII 线框稿把每个控件的位置用矩形区域定义出来-------------------------------------------- | PROG: 06 VERB: 37 NOUN: 06 | | ALT: 024.6 VSPD: 017 FUEL: 083 | | [ 七段数码管显示区 ] | | 1 2 3 PROG VERB NOUN | | 4 5 6 - ENTER | | 7 8 9 CLR 0 GO | | [ENGINE ON] [ABORT] [AUTO] [MANUAL] | --------------------------------------------这个线框稿直接映射成 config 里的矩形坐标渲染时统一用一个draw_rects()函数去绘制后期调整布局只需要改配置文件不用动 UI 代码。这个做法我一直沿用到项目结束强烈推荐。2.2 按键输入解析VERB / NOUN / PROG 的指令组合整个控制逻辑沿用了类似老式计算机的三段式指令结构程序号表示当前运行的程序动词表示要执行的动作名词表示动作的作用对象。比如输入 PROG 06表示切换到下降程序。输入 VERB 37 NOUN 06表示执行启动下降序列。输入 VERB 47 NOUN 00表示切入自动着陆模式。输入 VERB 90 NOUN 00表示中止任务回到待机状态。键盘输入解析的核心是一个当前输入域状态class CommandBuffer: def __init__(self): self.field PROG # 当前正在输入的字段 self.values {PROG: [], VERB: [], NOUN: []} def press(self, key): if key.isdigit(): self.values[self.field].append(key) elif key PROG: self.field PROG elif key VERB: self.field VERB elif key NOUN: self.field NOUN elif key CLR: self.values {k: [] for k in self.values} elif key ENTER: return self.compose_command() return None def compose_command(self): # 把三段数字拼成一个完整指令 prog int(.join(self.values[PROG][-2:])) verb int(.join(self.values[VERB][-2:])) noun int(.join(self.values[NOUN][-2:])) return {prog: prog, verb: verb, noun: noun}这个类非常简单但把输入三段数字再确认的交互逻辑处理得明明白白。CLR 用于清空当前输入每次 ENTER 后也要自动清空。注意这里有一个细节我允许输入多位数后再取后两位方便用户看到面板上已经有旧数字时直接覆盖输入。2.3 七段数码管与扫描线效果数码管的实现我没有用字体文件而是直接用线段绘制七段码。每个数字由七段组成每段是一个矩形我用一个字典存储每个数字需要点亮的段SEGMENTS { 0: [1, 1, 1, 1, 1, 1, 0], 1: [0, 1, 1, 0, 0, 0, 0], 2: [1, 1, 0, 1, 1, 0, 1], 3: [1, 1, 1, 1, 0, 0, 1], # ... 其余数字 } SEGMENT_COORDS [ (20, 0, 60, 8), # 上横 (68, 8, 72, 48), # 右上竖 (68, 52, 72, 92), # 右下竖 (20, 92, 60, 100),# 下横 (20, 52, 24, 92), # 左下竖 (20, 8, 24, 48), # 左上竖 (20, 46, 60, 54), # 中横 ]显示模块会预先渲染一份离屏 Surface只有数值变化时才重新绘制然后整体blit到面板上。这个优化很重要后面实测时我会细讲。扫描线效果则是每隔两个像素画一条半透明黑线模拟老式 CRT 显示器的横纹。效果很出彩但注意别把不透明度调太高否则数字会看不清。我最终用的是 alpha 30。3. 核心物理模型下坠、推力和燃料如果说控制面板是这艘飞船的脸物理模型就是它的内脏。我的模型不需要像专业飞行模拟器那样精确但几个关键的物理关系必须正确重力让飞船加速下落推力提供反向加速度燃料消耗直接影响质量变化最终落地的速度决定了任务是成功还是失败。3.1 运动学积分为什么用半隐式欧拉飞船的二维状态可以用四个量描述水平位置 x、高度 y、水平速度 vx、垂直速度 vy。推力可以分解为水平和垂直两个方向的力重力只在垂直方向起作用。运动方程写出来就是d(vx)/dt thrust_horizontal / mass d(vy)/dt (thrust_vertical - gravity * mass) / mass dx/dt vx dy/dt vy数值积分我采用了半隐式欧拉也就是先用加速度更新速度再用更新后的速度更新位置。别小看这个顺序问题显式欧拉先更新位置的话在边界条件下容易出现能量不守恒的振荡现象。半隐式欧拉虽然只多了一步用新速度算位置但稳定性明显更好。核心代码def step(self, dt: float): self.t dt # 推力方向和大小来自当前引擎设置 thrust_x self.thrust * math.sin(self.thrust_angle) thrust_y self.thrust * math.cos(self.thrust_angle) ax thrust_x / self.mass ay thrust_y / self.mass - self.gravity self.vx ax * dt self.vy ay * dt self.x self.vx * dt self.y self.vy * dt # 燃料消耗与当前推力大小成正比 self.fuel - self.burn_rate * self.throttle * dt self.fuel max(0.0, self.fuel) self.mass self.dry_mass self.fuel这里要注意几个参数的物理合理性。我设定的初始质量是 1000 公斤其中燃料占 300 公斤引擎最大推力是 3200 牛重力加速度设成 1.6 米每秒方。为什么推重比这么低因为低速环境下需要比较精确的节流如果推力太大每次调整油门都会造成剧烈的速度变化手动操作会非常难受。实际跑下来这个参数组合的操控手感刚好合适。3.2 手动模式油门与倾角的操纵手感手动模式的操作方式我用了两个非常直观的映射上下方向键调整油门百分比变化速率为每秒 20%。左右方向键调整引擎倾角范围限制在正负 15 度。为什么油门要做成渐变量而不是直接设成目标值因为在真实飞行器上油门是一个连续变化的物理量不可能瞬跳。虽然是模拟器但从按下到推力响应之间有个延迟这种手感会让着陆过程更有张力。我实际试过直接映射版本效果跟开关似的毫无操控乐趣。手动着陆的难度主要在于水平速度控制。垂直方向玩家会本能地盯住高度和下降率但水平漂移很容易被忽略。我在面板上特意加了一个水平速度指示用正负号表示方向这个信息比想象中有用得多。3.3 自动着陆一个可用的 PID 控制器怎么调出来自动模式的本质是把看着高度表调整推力这个过程变成一个控制算法。我用的是垂直速度 PID 控制先根据当前高度算出一个期望下降速度然后让控制器调节油门让实际垂直速度跟随期望值。期望下降速度曲线我定义为def desired_vy(self, altitude: float) - float: # 高度越高允许的下降速度越大低空逐渐减速 target -max(0.6, 0.6 (altitude - 5) * 0.08) return min(target, -2.5) # 不超过每秒2.5米这个曲线的物理意义是飞船在 50 米高度会以约每秒 2.5 米的速度下降接近地面时逐步减速到每秒 0.6 米以内。实际效果非常接近真实着陆任务的制动减速过程。PID 控制器的核心代码class PIDController: def __init__(self, kp, ki, kd): self.kp kp self.ki ki self.kd kd self.integral 0.0 self.prev_error 0.0 def update(self, error, dt): self.integral error * dt derivative (error - self.prev_error) / dt self.prev_error error return self.kp * error self.ki * self.integral self.kd * derivative油门指令不能直接输出 PID 结果因为飞船本身还要克服重力。正确做法是加上重力补偿如果飞船需要维持当前速度油门至少得提供和重力相等的推力。最终油门计算公式是gravity_compensation self.mass * self.gravity / self.max_thrust pid_output self.pid.update(desired - actual, dt) throttle clamp(gravity_compensation pid_output, 0.0, 1.0)这个前馈补偿PID 误差修正的结构比纯 PID 收敛快得多。后面调试部分我会给出具体的调参过程。4. 状态机与主循环任务按流程走代码不打架光有物理模型和界面还不够你得让整个任务按流程走。从待机到下降、到着陆、到结果判定如果全部用 if-else 散写后期必然乱套。我用了一个非常轻量的状态机来处理任务阶段切换。4.1 状态定义与流转逻辑整个任务分为五个状态STANDBY待机等待输入启动指令。DESCENT下降阶段飞船逐渐下降并调整姿态。FINAL最终阶段高度低于 15 米开始精确控制着陆速度。LANDED着陆成功。CRASHED着陆失败。状态流转表如下当前状态触发条件下一状态STANDBY收到启动下降指令DESCENTDESCENT高度低于 15 米FINALFINAL触地且速度在阈值内LANDEDFINAL触地且速度超限或燃料耗尽CRASHEDDESCENT / FINAL收到中止指令ABORTABORT重置操作STANDBY状态机实现不用引入复杂框架一个枚举加一个映射表就够了class State: STANDBY STANDBY DESCENT DESCENT FINAL FINAL LANDED LANDED CRASHED CRASHED ABORT ABORT TRANSITIONS { (State.STANDBY, START_DESCENT): State.DESCENT, (State.DESCENT, REACHED_FINAL): State.FINAL, (State.FINAL, LANDED): State.LANDED, (State.FINAL, CRASHED): State.CRASHED, (State.DESCENT, ABORT): State.ABORT, (State.FINAL, ABORT): State.ABORT, (State.ABORT, RESET): State.STANDBY, }每个状态是独立的处理函数。例如在 FINAL 状态每帧检查四个条件是否触地、垂直速度是否小于 0.8 米每秒、水平速度是否小于 0.3 米每秒、燃料是否耗尽。条件判断集中在一个函数里逻辑清楚也不会漏判。4.2 固定时间步逻辑和渲染分开这是整个项目最值得强调的一个设计也是我最初踩过最大的坑。很多人写游戏循环会直接把物理更新放在帧循环里并且用每帧的实际间隔时间作为 dt。问题在于显示器刷新率是不稳定的一旦掉帧物理模拟会明显变慢甚至产生奇怪的结果。解决方案是固定时间步。逻辑层总是按照固定的 dt 步进渲染层独立跑自己的帧率CLOCK_DT 0.05 # 逻辑步长每秒 20 次 accumulator 0.0 last_time pygame.time.get_ticks() while running: now pygame.time.get_ticks() frame_time (now - last_time) / 1000.0 last_time now accumulator min(frame_time, 0.25) # 防止窗口切换导致的巨大跳跃 while accumulator CLOCK_DT: state_machine.update(CLOCK_DT) # 固定步长更新逻辑 accumulator - CLOCK_DT ui.render(state_machine.snapshot()) pygame.display.flip()注意累计器有个上限min(frame_time, 0.25)这是为了防止窗口拖动时累计大量欠账导致物理瞬间跳很多步。这一行代码是多次实测后加上的非常实用。4.3 任务日志给每次飞行留一份档案没有数据的模拟项目是不完整的。我把每次飞行的关键状态按固定频率写入日志文件方便事后分析。日志格式是 JSON 数组每条记录包含时间戳、任务阶段、高度、速度、燃料、油门、指令等信息{ events: [ { time: 0.00, phase: STANDBY, altitude: 50.0, vy: 0.0, vx: 1.0, fuel: 300.0, throttle: 0.0 } ], commands: [ { time: 1.2, type: VERB_37_NOUN_06 } ] }写入时我做了原子写操作先写到临时文件再用os.replace覆盖旧文件。这是因为模拟结束瞬间如果程序崩溃普通写入很容易留下半截损坏文件。这个小习惯我已经沿用到了所有工具项目里。日志的用途很多着陆成功后可以画一张高度-时间曲线看飞船减速过程是否平滑失败时则可以精确判断是哪一个瞬间出了问题甚至可以逐帧回放。5. 实测问题与调试记录十几次失败换来的经验写代码的过程其实比想象中曲折很多问题只有把系统完整跑起来才会暴露。我把印象比较深的几个问题记下来这些问题在纯看代码阶段几乎不可能发现。5.1 渲染帧率拖垮物理模拟第一次把 UI 和物理引擎合并时我发现在飞船快速下落的时候整个系统变得「一卡一卡」的。一开始以为是图形绘制太慢后来把物理更新和渲染放在一起计时才发现问题出在逻辑更新依赖真实时钟间隔。帧率高时物理步长小帧率一掉物理步长一下子变大飞船直接穿地。解决方式就是前面说的固定时间步。改完之后就算我把窗口最小化再恢复模拟节奏也不会乱。这个问题的核心教训是逻辑更新和渲染必须解耦哪怕是个自娱自乐的小项目。5.2 PID 参数爆炸从振荡到收敛自动着陆模式第一次跑的时候飞船从 50 米高度直接振荡起来高度曲线像弹簧一样上下弹了三四次最后燃料耗尽坠毁。我当时的 PID 参数是凭感觉写的Kp 设了 2.0Ki 设了 0.1Kd 设了 0结果严重过冲。调试过程可以总结成三步先把 Ki 和 Kd 全部归零只保留 Kp从 0.5 开始慢慢加。加到 1.5 左右飞船开始出现轻微振荡我把这个值作为临界值。保留临界值的一半作为 Kp然后开始加 Kd。Kd 加到 0.8 时振荡明显消失。最后加 Ki 消除稳态误差。由于这个模型有重力补偿稳态误差其实很小Ki 只需要 0.05 到 0.1 就够了。最终稳定运行的参数是 Kp1.5Ki0.05Kd0.8。调参过程中日志功能发挥了巨大作用我每次改动参数后跑一次飞行对比高度曲线远比盯着屏幕看动画要高效。5.3 面板输入冲突自动模式下乱按没响应自动着陆进行中玩家如果切换到手动模式会发现按键完全没反应。不是程序卡住了而是状态机不接收手动按键指令。问题是这个拒绝毫无反馈面板上没有任何提示玩家会误以为游戏出了问题。我在状态机里加了一个处理逻辑当收到不被当前状态允许的指令时会触发一个INVALID状态灯闪烁三次同时在数码管底部显示短暂提示。这个改进虽然小但极大提升了使用体验。另一个更实际的处理是增加手动接管指令自动模式下按 VERB 49 NOUN 00立即切回手动模式给玩家随时接管控制权的机会。5.4 常见问题速查表现象可能原因排查与解决飞船侧向漂移越来越快自动模式只控制了垂直速度没有水平制动增加水平速度 PD 控制或提示玩家手动调整倾角燃料出现负数燃料消耗步进时没有做下限钳制每次更新后执行fuel max(0, fuel)同时把质量下限同步日志文件损坏写入过程被异常中断使用临时文件加os.replace原子替换按键输入偶尔没反应键盘事件被 UI 抢走没有进指令队列统一在事件循环顶层收口按状态分发给 UI 或逻辑层低分辨率笔记本跑不满 60 帧每帧全部重绘数码管和按键把静态面板元素缓存到离屏 Surface只有数值变化才局部重绘6. 后续还能怎么玩扩展方向与个人心得项目主流程跑通之后我又花了一点时间做扩展发现这个模拟器可以延伸的方向比想象中多而且每一个方向都能学到新东西。6.1 三个我觉得最有意思的扩展方向第一是加传感器噪声和风场。当前模型是理想化的没有随机扰动。加入噪声后自动着陆的鲁棒性会面临真实考验手动操控的难度也会明显上升。这会让项目从玩具变成可以拿来讨论控制策略的小工具。第二是回放数据可视化。日志里的高度、速度、油门数据已经足够丰富可以用图形把这些曲线画出来。看着着陆过程中速度曲线从每秒 2.5 米平滑收敛到 0会有一种强烈的成就感。画图不需要额外库用 pygame 自带的线条绘制就能实现。第三是移植到嵌入式设备。pygame 的代码分层让移植变得可行UI 层换成小屏幕驱动输入换成实体按键物理和状态机逻辑完全复用。如果有人想玩真实硬件这会是成本很低的入门项目。6.2 一点个人体会做完这个项目我最深的体会是先让系统能完整跑一遍再回头打磨细节。刚开始我花了大量时间纠结界面配色和按键阴影最后发现运动学和状态机没搞对时再好看的界面也毫无意义。后来我先用最简单甚至有点丑的控制台把所有逻辑跑通才回头做视觉优化效率高了很多。另外一个小建议像这种带状态演进的模拟项目单元测试非常值得写。不需要覆盖全部逻辑只要把动力学step和状态机流转做成纯函数并写几个测试用例后续改代码时会安心很多。我后来重构了整整两次都靠这些测试兜底没有回归出新的严重问题。最后分享一个我从这个项目一直保留到现在的小技巧画复古 UI 时不要直接在一张画布上手工怼坐标。先用 ASCII 或任意画图工具做一版线框稿把每个控件的矩形区域整理到配置文件里渲染时统一按配置绘制。这样后期调整布局只改配置不改代码省下的时间远超当初画线框稿花掉的那一丁点功夫。