用Python复刻Lemmings:多智能体状态机与像素地形碰撞实战

发布时间:2026/9/3 3:18:16
用Python复刻Lemmings:多智能体状态机与像素地形碰撞实战 如果你以为 Lemmings 只是一款 90 年代的休闲小游戏那你很可能低估了它。它表面上像“一堆小绿头发生物排队乱走”实际是一个多智能体实时调度系统每只旅鼠都是一个独立行为单元关卡本身是一个像素级地形状态玩家要做的是在有限技能和有限时间内分配行为、开辟路径、控制损失。理解了这一点就不难理解为什么很多游戏开发者会把 Lemmings 当成状态机、寻路、碰撞检测和资源调度的绝佳教学案例。本文不打算还原原版素材而是从实现角度做一个可运行的 Lemmings 行为框架。主角是 Python 和 Pygame核心内容围绕旅鼠的基础状态机、像素地形碰撞、挖掘/搭桥等技能如何修改地形、如何用固定时间步做批量关卡回放。读完之后你既能跑通一个最小 Demo也能把里面的状态机思维迁移到批量任务调度、仿真 Agent、机器人路径规划等场景。1. Lemmings 核心玩法本质与能力速览先给一张“规格表”帮助你判断这套内容适不适合自己。这里讨论的是经典《Lemmings》玩法的编程实现不是官方游戏本体也不包含任何受版权保护的图片和音效资产。维度说明玩法类型多人控制、资源有限的即时解谜行为实体旅鼠每个个体按状态机独立行动不需要玩家逐个点击操纵核心资源各类技能数量例如挖掘、搭桥、炸开地形、撑伞、加固等关卡目标在时间限制内让足够数量的旅鼠到达出口地形模型像素级二维网格技能会动态修改地形数据主要算法有限状态机、像素碰撞检测、寻路/打通路径、技能调度关键技术点同屏多对象更新、地形与实体交互、确定性回放适合学习人群游戏开发新手、AI Agent 入门、想理解批量仿真的人不适合的场景追求高质量美术、复杂物理、大型商业玩法原型从工程角度看Lemmings 的价值模型可以简化成三个问题如何让成百上千个单位不靠脚本硬编码也能“看起来有智力地行动”如何设计一套技能系统让玩家通过改变地形来间接影响单位运动又如何保证同样的操作序列能在批量仿真里稳定复现后面所有内容都会围绕这三条线展开。2. 适用场景与实现边界Lemmings 这类问题适合用来做算法原型是因为它的规则边界清晰一个最小版本不需要复杂渲染和网络同步。你可以把它用于学习有限状态机的基本写法。实践多实体碰撞与地形改造。验证“行为队列 技能调度”的批处理模型。做自动化关卡测试用脚本批量跑多种技能分配方案比较存活数。这类内容也有明显边界不要踩不要直接使用官方“Lemmings”的美术素材、音频和关卡图做公开分发除非你拥有授权或使用了可替换的原创素材。不要把行为框架包装成“完全复刻原版”的商业产品。如果后续做人群仿真、机器人调度还要额外考虑真实物理规则和动态障碍物纯游戏式判定在真实环境中只适合作为粗粒度思路。只要把边界控制好用 Python 实现一套简化版 Lemmings 是完全可行的。它不仅是可以玩的小游戏更是一个能跑通“多 Agent 像素地形 批量测试”的轻量实验环境。3. 环境准备与前置条件这里以 Python 3.10 加 Pygame 为例。渲染不做特别复杂的效果Pygame 负责窗口、事件和画面输出核心逻辑全部写在自己的类里方便后续脱离渲染层做批量测试。先建立项目目录和虚拟环境mkdir lemmings_demo cd lemmings_demo python -m venv .venv # Windows .venv\Scripts\activate # Linux / macOS source .venv/bin/activate安装 Pygame 社区版它比老版 Pygame 维护更积极在 Windows 和 macOS 上的安装体验更好pip install pygame-ce如果你只想跑逻辑仿真不需要窗口可以连 Pygame 都不装。但这里为了直观验证“旅鼠状态变化”还是建议保留一个最小渲染窗口。环境准备好之后项目结构可以这样组织lemmings_demo/ ├── main.py # 启动入口渲染与主循环 ├── terrain.py # 像素地形读取、碰撞检测、地形修改 ├── lemming.py # 旅鼠状态机与行为逻辑 ├── scenario.py # 关卡配置与批量测试入口 └── scenarios/ └── demo.json # 场景参数4. 最小启动用 Pygame 跑通一个旅鼠先不着急实现完整技能直接跑通一只“会走路、会坠落、会掉头”的旅鼠。核心步骤分三块地形、碰撞、主循环。4.1 像素地形表示Lemmings 的地形思路最值得注意的一点是像素级碰撞而不是网格块碰撞。关卡地图不是一堵堵完整方块墙而是一个二维数组每一个元素代表一个像素点是否可站立。# terrain.py def build_map(width: int, height: int) - list: 生成最简地形底部是一条水平地面中间放一个障碍物。 0 表示空1 表示实心地形。 terrain [[0 for _ in range(width)] for _ in range(height)] # 底部地面 for x in range(width): terrain[height - 20][x] 1 # 测试用障碍 obstacle_y height - 120 for y in range(obstacle_y, obstacle_y 30): for x in range(120, 160): terrain[y][x] 1 return terrain这里用 1/0 的二维数组表示地形。后续搭建、挖掘、爆破等技能本质都是对数组做局部修改。4.2 碰撞检测函数碰撞检测只需要判断一个像素点是否被地形占据。考虑到边界问题落地画面外通常视为“危险但可坠落”画面左右边界则应该避免实体走出去。# terrain.py def is_solid(terrain: list, x: int, y: int) - bool: height len(terrain) width len(terrain[0]) # 左右边界当墙处理防止旅鼠无限横向移动 if x 0 or x width: return True # 允许往下掉落超出底部按非实心处理 if y 0: return True if y height: return False return terrain[y][x] 1在游戏主循环里可以取旅鼠“脚下像素”和“前进方向像素”分别判断形成最简单的行走判定。4.3 最小旅鼠类下面代码是行为骨架不代表最终完整物理引擎但已经足够说明状态机的核心思路# lemming.py class Lemming: def __init__(self, x: int, y: int, direction: int 1): self.x x self.y y self.direction direction # 1 表示向右-1 表示向左 self.vy 0 # 垂直速度 self.state walk # 当前状态 self.fall_frames 0 # 已经坠落的帧数 def update(self, terrain): if self.state walk: self._update_walk(terrain) elif self.state fall: self._update_fall(terrain) def _update_walk(self, terrain): front_x self.x self.direction front_y self.y # 前方被堵掉头 if is_solid(terrain, front_x, front_y): self.direction * -1 return # 前方是空的先继续走一步 self.x self.direction # 脚下没有支撑进入坠落状态 if not is_solid(terrain, self.x, self.y 1): self.state fall self.vy 0 self.fall_frames 0 def _update_fall(self, terrain): self.vy 1 self.y self.vy self.fall_frames 1 if is_solid(terrain, self.x, self.y): # 从实心像素里弹出避免嵌到地形中 self.y self.y - 1 while is_solid(terrain, self.x, self.y): self.y - 1 # 这里可以按坠落帧数判断是否摔死 if self.fall_frames 30: # 简单起见只标记死亡不真正移除 self.state dead else: self.state walk self.vy 0可以看到行走状态下旅鼠只有两种决策前面有地形就反向。脚下没有支撑就转换成坠落状态进入自由落体再在落地后恢复行走。这就是所有高级行为的地基。你后续增加“挖洞”“搭桥”“撑伞”时并不需要推翻这个框架只需要在对应状态分支里注入新的行为逻辑。4.4 主循环启动主循环负责固定帧率更新并处理玩家输入。这里给一个最小编程入口用来确认地图和旅鼠都能正常跑起来# main.py import pygame from terrain import build_map, is_solid from lemming import Lemming WIDTH, HEIGHT 480, 320 FPS 60 def main(): pygame.init() screen pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption(Lemmings Framework Demo) clock pygame.time.Clock() terrain build_map(WIDTH, HEIGHT) demo_lemming Lemming(WIDTH // 2, HEIGHT - 40, direction1) running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False # 更新旅鼠 demo_lemming.update(terrain) # 绘制地形 screen.fill((20, 20, 30)) for y, row in enumerate(terrain): for x, cell in enumerate(row): if cell 1: screen.set_at((x, y), (160, 160, 180)) # 绘制旅鼠 if demo_lemming.state ! dead: screen.set_at((demo_lemming.x, demo_lemming.y), (0, 255, 120)) pygame.display.flip() clock.tick(FPS) pygame.quit() if __name__ __main__: main()启动命令很简单python main.py能正常打开一个 480x320 窗口并看到一只绿色像素点不断移动、在障碍物处掉头就说明基础状态切换没有问题。5. 核心算法Lemmings 状态机设计只做一只旅鼠的移动显然不够Lemmings 的难点在于大量单位共享同一套行为模板但每个单位又可以通过“技能标记”被赋予不同职责。5.1 基础状态划分可以把经典玩法中的行为抽象成如下状态状态触发条件行为walk脚下有地面前方可通行横向移动遇到地形自动掉头fall脚下无支撑垂直下落落地后回 walk若落地速度过大则死亡climb玩家分配攀爬技能沿垂直墙面向上移动floater玩家分配降落伞技能坠落速度被压低不会摔死builder玩家分配搭桥技能在当前位置搭建阶梯改变地形digger玩家分配挖掘技能向下挖穿垂直通道basher玩家分配挖掘技能向正前方水平挖掘miner玩家分配向下斜挖技能沿 45 度方向斜向下挖bomber玩家分配爆破技能原地爆炸清空周围地形自身死亡blocker玩家分配阻挡技能站在当前位置让其他旅鼠无法继续前进这些状态并不是各自独立散乱的逻辑。实现时可以统一用“技能标记”来决定行为走向利用有限状态机做状态迁移。5.2 状态迁移优先级一个旅鼠不能同时又是挖掘者又是建造者所以需要在每一帧更新时先确认状态优先级。一种可行的做法是如果当前不是 walk/fall 等基础状态直接执行技能行为不执行普通移动。如果玩家给这只旅鼠分配了技能则在下一个可执行帧把技能写入“尚未开始的技能状态”。如果行为条件不满足例如没有地表可以挖掘则技能失效回到普通状态。下面用伪代码描述状态机更新层def update_lemming(lemming, terrain, frame): # 1. 当前有技能且尚未开始则尝试进入技能状态 if lemming.pending_skill and lemming.state not in SKILL_STATES: if can_use_skill(lemming, lemming.pending_skill, terrain): lemming.state lemming.pending_skill lemming.skill_frames 0 lemming.pending_skill None # 2. 按状态执行逻辑 if lemming.state walk: update_walk(lemming, terrain) elif lemming.state fall: update_fall(lemming, terrain) elif lemming.state builder: update_builder(lemming, terrain) elif lemming.state digger: update_digger(lemming, terrain) elif lemming.state floater: update_floater(lemming, terrain) ...这种写法的好处是方便扩展。想把官方技能换成一整套自定义行为只需要增加一个状态类或状态处理函数。5.3 技能调度表在 Lemmings 里玩家不会手动控制具体每一帧只会“在某个时间点、对某只旅鼠使用某个技能”。这等同于批量任务里的一次任务分配。实现时可以用一个字典保存“待执行技能队列”# 为每一只旅鼠分配技能 skill_queues { 0: builder, 5: digger, 12: floater, } for lemming_id, skill in skill_queues.items(): lemming lemmings[lemming_id] lemming.pending_skill skill这里“编号”就是旅鼠 ID。实际项目中玩家更习惯直接点击一只旅鼠但内部逻辑仍然是对应着一个 ID。合理拆出“行为分配”和“行为执行”后后面自动化测试就非常方便了。6. 像素地形与实体交互挖掘、搭桥、爆炸的核心逻辑Lemmings 另一个关键点是技能的实质结果不是改变某些数值而是直接修改地形数组。下面拆解三种最典型的地形操作。6.1 挖掘向下删除像素挖掘的本质是把以旅鼠为中心、向下若干像素的实心区域替换成 0。最简单版本如下def carve_rect(terrain, x: int, y: int, w: int, h: int): height len(terrain) width len(terrain[0]) for yy in range(max(0, y), min(height, y h)): for xx in range(max(0, x), min(width, x w)): terrain[yy][xx] 0挖掘技能状态中每执行 8 帧左右调用一次carve_rect清除旅鼠脚下的一块区域并将旅鼠向下移动一点def update_digger(lemming, terrain): # 沿脚下挖掉一个矩形区域 carve_rect( terrain, lemming.x - 6, lemming.y, 12, 8, ) # 旅鼠逐渐下沉 lemming.y 3这个版本的“挖”没有严格物理模拟但效果已经非常接近地形消失后旅鼠会向下移动直到没有再可站立的实心像素为止。6.2 搭桥向上铺设像素搭桥的常见技能是建造一截阶梯。实现同样简单旅鼠每走一段距离就在自己脚下当前位置放置若干实心像素从而改变地形。STONE_GRAY 1 # 在像素地图里地形只需要用不同数值区分类型 def place_bridge(terrain, lemming, build_steps): x lemming.x y lemming.y # 每铺设一“块”在目标位置放置一个 16x8 的小台阶 for i in range(6): for yy in range(max(0, y - 8), y): for xx in range(x, x i * 2 6): terrain[yy][xx] STONE_GRAY真实搭桥过程需要按“桥面向右上方延伸”逐步放置像素不能一次生成一大块。生产级实现里应该限定每帧只修改一小段地形让动画看起来连续而不是瞬间出现一条长楼梯。6.3 爆炸圆区域清除与连锁问题爆破技能会清空以旅鼠为中心的圆区域同时重新计算地形连通性。像素地图存在一个需要注意的问题直接删除一块圆形区域后原本由该区域支撑的上层地形可能会变成悬空。具体处理方式取决于玩法目标如果追求物理真实需要引入简单的“落沙”或“考虑重力”的地形更新逻辑。如果只是为了复刻老式地图手感通常只做像素删除然后允许上层地形保持悬空。爆破后的地形会破坏已有路径也可能打通新的通道。因此每次爆破后都应从所有旅鼠位置重新检测可走区域避免某只旅鼠被埋进地形内部或错误地判断为可通行区域。6.4 像素碰撞的代价与优化像素地图实现直观、清晰但随着地图变大或旅鼠数量增多频繁调用逐像素扫描会成为性能瓶颈。建议在设计阶段就做好空间管理把像素地图按块预先分桶例如每 16x16 像素一个区域。碰撞检测前先判断旅鼠所在块。地形被修改后只标记对应块需要刷新而不是整张地图重算连通性。这样能极大降低查询成本。7. 批量关卡测试与回放没有 API 也要有确定性仿真入口Lemmings 这类玩法天然适合批量测试。因为决定关卡结果的不只是玩家“点了哪只旅鼠”还包括“在哪一帧点、按什么顺序点”。如果能固定输入时间序列算法输出应该稳定可复现。这种确定性非常重要。我建议直接做 CLI 仿真绕开 Pygame 渲染层每次运行只输出结果。7.1 场景配置先用 JSON 描述一次关卡测试{ map_level: 0, target_saved: 10, time_limit_frames: 10800, lemmings: 30, spawn_interval_frames: 30, skills: { builder: 5, floater: 6, digger: 2, bomber: 3 }, assignments: [ { lemming_id: 1, skill: floater, frame: 120 }, { lemming_id: 2, skill: builder, frame: 180 } ] }这里frame是相对关卡开始时的绝对帧数lemming_id是旅鼠编号。这样一套配置就能完全描述一局自动测试的输入。7.2 Python 批量仿真入口批量入口的核心逻辑是不要依赖 Pygame 事件而是直接逐帧调用世界更新并定时把技能写入对应旅鼠。def run_batch(scenario: dict, max_frames: int) - dict: world LemmingsWorld(scenario[map_level]) lemmings world.lemmings saved 0 result { saved: 0, dead: 0, frame: 0, success: False, } assignments sorted(scenario.get(assignments, []), keylambda item: item[frame]) for frame in range(max_frames): # 根据出生间隔生成新旅鼠 if frame % scenario[spawn_interval_frames] 0: world.spawn_lemming() # 执行技能分配 while assignments and assignments[0][frame] frame: item assignments.pop(0) lemming lemmings[item[lemming_id]] lemming.pending_skill item[skill] # 更新世界 world.update() # 统计结果 if world.saved_count() scenario[target_saved]: result[saved] world.saved_count() result[frame] frame result[success] True return result result[saved] world.saved_count() result[dead] world.dead_count() result[frame] max_frames result[success] False return result有了这个批量入口就能在本地自动跑几百个技能分配方案比较哪种方案能让最多旅鼠存活。这本质上就是一个无界面的任务调度系统。8. 资源占用与性能优化思路Lemmings 的“同屏大量实体”天然容易卡尤其是使用像素级地形检测时。这一节不写固定数字而是给一套自己的验证和调优方法。8.1 观察重点在跑自己的 Demo 时你需要重点观察每帧旅鼠数量是否稳定出生瞬间是否出现明显掉帧。碰撞检测与地形修改的执行频率。每次爆破/挖掘后是否有大量地形数据被重复遍历。显示器刷新率与逻辑更新频率是否分离。建议游戏逻辑固定使用 60Hz 更新渲染可以单独使用垂直同步。这样即使屏幕卡顿批量仿真结果依然可以复现。8.2 常见瓶颈与处理性能瓶颈原因优化方案旅鼠数量增加后整体变慢每帧对所有旅鼠都做像素检测限制同屏数量超出屏幕视野的旅鼠使用简化更新地形修改后卡顿在地形修改后立即全图重新判连通只标记受影响区域延迟到绘制或寻路阶段再计算大量空区域仍被扫描二维数组遍历整张图引入分块索引或空间哈希渲染窗口影响逻辑性能使用 set_at 逐像素绘制使用 Surface 缓存地形只在修改区域局部重绘批量测试时仍渲染画面批量脚本里启动 Pygame 窗口把逻辑层和表现层彻底拆开9. 常见问题与排查方法下面整理这个学习框架里最常遇到的问题以及对应的排查方向。问题现象可能原因排查方法解决方案旅鼠走到面前后原地抖动左右边界和前方判定同时成立不断反向检查前方像素与当前像素是否处于同一高度前方检测判断“下一个可移动格”而不用当前格判断掉落后直接消失落地判定把刚接触的顶面当作实心但旅鼠已经嵌入地形打印落地时 y 坐标与地形值弹出到地形上方后再恢复 walk降落伞技能不生效坠落过程中未进入 floater 状态检查状态进入条件是否只在技能开始时执行在fall状态下也要检查技能列表挖掘后旅鼠卡住不动挖掘函数挖掉地形但旅鼠没有向下同步打印挖掘前后的坐标和 terrain 值每帧将旅鼠 y 增加若干像素搭桥后旅鼠跳过桥面铺桥时桥面生成太慢旅鼠已经走到前方空区域检查桥面生成速度与旅鼠移动速度让桥面生成速度略高于旅鼠横向移动速度批量测试结果不稳定渲染帧率影响逻辑更新次数检查主循环是否用dt驱动使用固定时间步累加逻辑每 1/60 秒更新一次地图修改后大量旅鼠走进死路技能生成地形后没有更新路径信息使用可通行区域查找调试图层在绘制时标出每个区域连通性排查 Lemmings 类问题有一个通用思路把“表现”和“状态”分开看。渲染层卡顿不一定是逻辑卡顿旅鼠看起来在抖动不一定是方向判断错误有可能是前后帧的像素判定点不一致。建议在核心类里增加一行日志输出每 30 帧打印当前状态、坐标、方向、技能状态能快速定位八成问题。10. 从游戏复刻到生产级调度这套状态机思想能用到哪如果只把 Lemmings 当作一个小游戏看待这套分析的价值有限。但把它抽象成“大量实体在共享环境中被少量指令间接控制”的问题后能迁移的方向就非常多了。批量爬虫任务调度每个任务节点就像一个旅鼠平台资源就是地形技能分配等同于资源分配。机器人仓库导航多台机器人共享同一张动态地图遇到堵塞时通过状态机切换策略比集中式全局规划更有韧性。人群疏散仿真行人不是一个个被直接控制的而是按照“前方可走则走、不可走则换方向”的低成本规则移动Lemmings 的 fall/walk 状态切换就是最朴素的人群规则。自动游戏测试像第 7 节那样用固定帧 行为配置批量测试不同策略能够在没有人工操作下评估不同技能组合的效果。这套写法的核心是“把智能下沉到行为层而不是每次都由中央控制器下达全量指令”。在 Lemmings 里玩家看似在指挥所有旅鼠实际只是通过很少的技能标记影响了个别实体让它们做出局部决策最后涌现出整体路径。这个概念比像素地形或者 Pygame 本身更值得带走。初次尝试时建议把目标控制得很小先写一只旅鼠让它能走、能掉头、能坠落然后加入 10 只旅鼠验证状态机是否稳定再加入一个搭桥技能观察桥面生成和旅鼠通过的节奏最后再加入批量命令行测试用 JSON 把一个完整方案固定下来。每完成一步都是独立实验中间出问题的概率会低很多。