游戏如何真正运转:启动、时钟、输入与每一帧
本章依据提交 0dc09bb1d6188d1ec79ab4765b22a48b75ffee32 的函数实现。这里的“帧”首先指游戏世界前进一步,不能直接等同于显示器刷新一次。把这一点和执行顺序读清楚,后面的移动、战斗、联机才有共同的时间坐标。
1. 一个程序,有三层生命周期
第一层是进程。STARTUP.CPP 用条件编译选择 Windows 的 WinMain 或非 Windows 的 main;它承担平台准备,再进入 Main_Game。Windows 分支建立 WinTimerClass(60, FALSE),非 Windows 分支调用 Init_Timer_System(60, true)。因此,不应把全部启动工作都归在 Main_Game,也不应把这个仓库理解成只有一种操作系统入口。平台入口、计时器安装、进入游戏主体。
第二层是一场游戏。Main_Game 先执行一次 Init_Game,随后在 while (Select_Game(fade)) 中反复选择模式、进入关卡、结束并返回菜单。第三层才是一帧:每局内部反复调用 Main_Loop(),返回值表示是否结束当前循环。Main_Game、非编辑器循环。
| 层次 | 主要入口 | 管理的对象 |
|---|---|---|
| 程序启动 | main / WinMain、Init_Game |
平台、公共资源、类型定义、预分配内存 |
| 进入一局 | Select_Game、Start_Scenario、Read_Scenario |
模式、地图、阵营、实例、任务状态 |
| 推进世界 | Main_Loop、Logic.AI、Queue_AI |
一轮对象行为、待执行命令、时间推进 |
这个分层解释了一个熟悉的体验:换关卡时不必重新启动整个程序,也不必把所有公共资源都重新加载。
2. 初始化顺序为什么不能随意调换
Init_Game 先做密钥和 Bootstrap 初始化,然后处理鼠标、光盘访问和后续 MIX 资源包。真正值得注意的是随后两种“堆”的先后关系:
- 按
HOUSE_COUNT、UNIT_COUNT等枚举数量建立类型对象池; - 调用各
*TypeClass::Init_Heap()填入已有类型; - 读取
RULES.INI,调用Rule.Process;部分编译分支还处理AFTRMATH.INI; - 最后
Init_Heaps按规则里的UnitMax、BuildingMax等数量建立实际游戏对象池。
这说明规则文件处理时,坦克、步兵、建筑的类型对象已经存在;规则是在既有类型框架上设置数据。与此同时,实例池容量依赖规则,必须等规则读取后再分配。源码注释也明确承认类型池先行属于解析器能力限制下的安排。初始化主体、实例池分配。
公共资源完成后,各子系统还需要 One_Time:地图、逻辑、选项、会话和各种类型分别建立自己的长期状态。它与新关卡的 Init 不是同一个概念。Init_One_Time_Systems。
进入关卡时,Select_Game 会判断是否已经读入存档;若已加载,就跳过重新 Start_Scenario。新关卡经 Start_Scenario → Read_Scenario 进入,Read_Scenario 清理旧场景,并暂时增加 ScenarioInit。Clear_Scenario 重置任务计时器、触发器列表、地图单元、对象池使用状态和选择列表。地图此时只做 Init_Clear,因为地形主题尚未确定,不能贸然重新加载对应资源。新关卡与存档分支、场景读取入口、场景重置。
3. 三种“时间”,不要混为一谈
源码同时出现 TIMER_SECOND = 60、TICKS_PER_SECOND = 15 和变量 Frame。它们表达不同层次:
| 名称 | 实现含义 | 使用举例 |
|---|---|---|
| 系统计时 tick | 平台定时器的计数,初始化目标为每秒 60 次 | 一帧还需要等待多久、网络超时 |
逻辑 Frame |
正常完成一轮主循环后递增一次 | 移动、武器冷却、任务状态机 |
| 规则中的“秒” | 以 TICKS_PER_SECOND = 15 换算成逻辑帧的名义尺度 |
任务延迟、修复周期、场景倒计时 |
FrameTimerClass 直接返回全局 Frame;SystemTimerClass 返回平台计时器的当前值。两种时基都可以放进 CDTimerClass<T>,因此变量名相近并不意味着时基相同。尤其全局 FrameTimer 的实际类型是 CDTimerClass<SystemTimerClass>:它用系统时间控制帧速率,而不是由逻辑帧自减。时间常量、两种时基、FrameTimer 定义。
这些倒计时也不是“每帧遍历所有计时器减一”。它保存起点和延迟,需要读取时才计算已过时间,并将剩余值下限夹到零。用 FrameTimerClass 的倒计时会随着 Frame 推进,用系统时基的倒计时则随真实时间推进。CDTimerClass::Value。
速度滑块改变的是世界推进速度
普通模式下,Main_Loop 用 Options.GameSpeed 设置系统倒计时,还按难度加减一个 tick;速度为零时走单独分支。特定多人压缩协议使用 TIMER_SECOND / Session.DesiredFrameRate;该分支播放录像时置零,尽快推进。FIXIT_VERSION_3 还补了 DesiredFrameRate 为零时的防除零处理。速度控制完整分支。
举个用于理解公式的假设值:正常难度、GameSpeed=4、机器足够快,则每逻辑帧目标约为 4/60 秒,即 15 帧/秒;若改成 2,则约为 30 帧/秒。同样一个 150 逻辑帧的倒计时,真实时间大致从 10 秒变成 5 秒。这里不是报告默认设置,也没有实测帧率;它只是代码关系的计算示例。
处理本身若已经超过目标时间,FrameTimer 会读作零,不再等待。这里没有看到现代常见的“累积真实时间,然后一口气补多个固定步长”的主循环结构。因此“游戏永远固定运行在 15 Hz”是错误概括:15 是规则换算基准和历史目标,实际推进速度还受设置、协议和机器性能影响。
4. 一帧到底先做什么
以下是保留关键顺序的解释性伪代码,省略调试、平台及部分结算分支:
if (!GameActive) return true;
设置本帧系统倒计时;
if (允许本地输入和显示) {
Map.Input(...);
Keyboard_Process(...);
Map.Render();
}
按需记录或恢复视角、选择状态;
按编译分支排序地面显示层;
Logic.AI();
处理消息过期;
Queue_AI();
Call_Back();
处理胜利、失败、重开;
Frame++;
Sync_Delay();
return !GameActive;最容易读错的是:主循环中的 Logic.AI() 在 Queue_AI() 之前,常规 Map.Render() 又在它们之前。 不能凭借现代引擎印象,改画成“读输入→执行命令→更新世界→统一渲染”。这里先推进已有状态,再执行本轮到期的玩家事件;这些事件设置的新行为通常交给后续逻辑处理。输入、渲染及逻辑处理、事件与结算检查、帧计数、帧末等待。
但这仍不是“每帧只渲染一次”:Sync_Delay 等待倒计时到零的过程中,反复做调色板循环、Call_Back,以及条件允许时的输入和 Map.Render()。所以界面有机会在两个逻辑步骤之间继续响应。此处确认的是调用机会,不能推断每次调用都全屏重画,也不能推断显示刷新率恒定。Sync_Delay。
还有一个跨系统细节:未定义 SORTDRAW 时,地面层排序被放在主循环里、绘制判断之外,注释直接说明是为了保持各机器遍历顺序一致。显示层顺序可能被其他游戏处理使用,它不是完全与模拟隔离的视觉数据。排序与同步。
5. 实例:点一块地,坦克为什么不会在鼠标函数里瞬移
先区分“界面操作”与“改变共同战局的命令”。例如键盘选择下一对象,Keyboard_Process 直接取消旧选择、选中新对象、居中地图并设置重绘标记;选取当前视野中的单位也是直接调用地图选择函数。而改变联盟关系则写入 OutList。因此这里的设计不是把每一个鼠标位移、每一个按键都广播成战局事件;进入队列的是相应的游戏命令。录像还要额外记录视角和选择,原因也正在于它们不能仅靠移动、攻击等世界事件重建。本地选择与视角、联盟事件与视野选择对比。
假定一辆坦克已经选中,玩家给它普通移动指令:
- 地图输入系统识别点击含义,最终进入对象的
Active_Click_With。FootClass对格子重新调用What_Action(cell);移动时可能先找相同通行区域附近的可到达位置,然后调用Player_Assign_Mission(MISSION_MOVE, TARGET_NONE, destination)。这里尚未把坦克坐标设成目的地。FootClass 点击处理。 Player_Assign_Mission可以马上播放应答语音,再将命令交给Queue_Mission;按住对应排队移动按键时改成MISSION_QMOVE。Queue_Mission把对象、任务、攻击目标、移动目的地封装为EventClass,加入OutList。玩家任务入口、事件入队。- 单机、遭遇战使用
Queue_AI_Normal,将OutList搬到DoList,随后执行到期事件;联机与录像由Queue_AI分流到各自处理路径。因此单机同样使用命令队列。队列分流、单机队列处理。 EventClass::Execute对命令对象做检查、解除相关联络或队伍关系,再执行Assign_Mission、Assign_Target和Assign_Destination。普通移动清空旧导航队列,排队移动则使用Queue_Navigation_List。任务事件生效。- 下一次适当的对象更新里,车辆接纳新任务;寻路与行驶代码分多帧改变位置,再将相关格子标记需要重画。播放语音、接受命令、开始行驶,是三个不同阶段。
若点击发生在帧首,当前帧的 Logic.AI 先按旧状态走一次,然后才处理这个事件;若点击发生在 Sync_Delay 内,事件等下一轮 Queue_AI。这解释的是源码时序,实际输入到运动的延迟还取决于网络调度、车辆正在做什么、是否允许切换任务,不能笼统声称“所有操作刚好延迟一帧”。
6. AI() 不只指电脑玩家
LogicClass::AI 是世界更新总调度器。它处理的范围包括人类控制的单位、动画、工厂和地图;这里的 AI 可理解为“每轮自主处理”。仅把它翻译成“敌方人工智能”,会漏掉大半引擎。
函数中的重要顺序为:
| 次序 | 处理内容 | 对游戏的意义 |
|---|---|---|
| 1 | 场景色彩渐变;全局、桥梁、时间类触发器 | 剧情与任务条件可以在对象更新前改变世界 |
| 2 | 清理临时触发标记;按规则恢复遮罩 | 维持场景状态 |
| 3 | 各 TeamClass::AI;时空漩涡等全局现象 |
团队先下达协调动作 |
| 4 | 遍历 Logic 中对象,调用虚函数 obj->AI() |
单位、子弹、动画等按各自实现更新 |
| 5 | 重算阵营属性;Map.Logic() |
整理共享统计和地图逻辑 |
| 6 | 工厂 AI;阵营 AI |
推进生产、管理阵营行为 |
具体边界以实现为准:前半段见触发器与队伍调度,后半段见对象、地图、工厂及阵营。FIXIT_VERSION_3 还调整了多人模式遍历阵营的起点。
这些处理是在同一条显式调用链里顺序进行,不是每个单位一个操作系统线程。单位的 AI() 还可能删除自己,因此遍历后比较当前位置是否仍是原对象,变化则 index--,防止紧接着的对象被跳过。这体现了边遍历边修改容器的设计;不能未经检查就并行化,也不能随意改为现代范围循环。对象删除后的索引修正。
7. 每帧更新与任务状态机是两层调度
对象每轮收到 AI(),不代表它每轮都重新思考完整任务。MissionClass::AI 先调用 ObjectClass::AI;对正在空降的步兵、车辆、船只暂停任务处理;只有任务计时器归零且生命值大于零,才按 Mission 调用 Mission_Attack、Mission_Move、Mission_Harvest 等虚函数。任务函数返回的整数成为下一次调用前的逻辑帧延迟。任务调度。
Mission 是正在执行的任务;MissionQueue 是一个待接纳任务槽,不是通用无限命令列表;Status 是任务内部状态。Assign_Mission 写待执行槽,Commence 将它转为当前任务,同时把 Timer 和 Status 清零。头文件关于“队列保存值加一”的旧注释与当前赋值代码不一致,理解当前版本必须以实现为准。字段定义、接纳与指派。
这里的“任务”还有另一个容易混淆的层次:单位 MissionClass::Timer 控制行为函数何时再次运行;场景 Scen.Timer 统计关卡已经经过的逻辑时间;Scen.MissionTimer 则是会显示给玩家、可以触发限时任务条件的全局倒计时。三者虽然都可基于 Frame,其所有者与业务含义完全不同。调试“矿车多久重新考虑采矿”和“任务倒计时为什么结束”,应当从不同计时器入口开始。场景两类时间、单位任务计时器。
车辆 UnitClass::AI 在地面、不卸矿、不处于行驶过程且舱门关闭时,才在相应位置尝试 Commence。接着调用父类更新,还会处理开火、炮塔旋转、装弹和运输舱门。因此任务的低频决策与车辆每帧运动、武器处理可以共存;这是一种手写状态机和计时器调度,而不是脚本虚拟机为每辆坦克启动一个线程。车辆更新。
8. 等待、对话框、结算仍是程序的一部分
Call_Back 维护音频回调、主题音乐、语音以及网络或串口服务;它不是 Logic.AI 的替代品。等待帧到期时可以继续服务音频和通信,而不让整个世界额外推进。Call_Back。
选项等特殊对话框在 Main_Loop 外层调用,源码给出的原因是对话框自身可能调用 Main_Loop,让游戏在背景继续运行。因此看到“弹窗”不能立即断言模拟已暂停;必须检查具体对话框、模式和调用路径。特殊对话框安排。
胜负也不是任意对象一判断就退出进程。对象与规则处理设置全局结果标记,主循环集中检查并调用 Do_Win、Do_Lose、Do_Restart。退出当前局后,外层淡出、关闭录像文件、按会话类型结束网络连接,并恢复回放相关状态,再返回游戏选择。结算分支、每局清理。
9. 阅读路线与尚未验证的边界
建议依次读:STARTUP.CPP 的入口与时钟安装 → Init_Game 的类型池/规则/实例池顺序 → Main_Game 的内外循环 → Main_Loop 与 Sync_Delay → LogicClass::AI → MissionClass::AI/Commence → UnitClass::AI。然后带着“这一行发生在第几轮、使用哪个时基、会不会立刻删除对象”三个问题,进入对象模型与内存。
本章确认了源码调用与数据关系,未编译运行原游戏,未测量实际帧率、输入延迟、焦点丢失或所有模式下的暂停表现。WIN32、SCENARIO_EDITOR、SORTDRAW、FIXIT_VERSION_3、FIXIT_CSII 等宏会改变局部行为;分析某个历史发行版时还需要匹配它实际使用的构建配置。