用Python复刻Lemmings:多智能体状态机与像素地形碰撞实战
2026/9/17 0:18:23 网站建设 项目流程

如果你以为 Lemmings 只是一款 90 年代的休闲小游戏,那你很可能低估了它。它表面上像“一堆小绿头发生物排队乱走”,实际是一个多智能体实时调度系统:每只旅鼠都是一个独立行为单元,关卡本身是一个像素级地形状态,玩家要做的是在有限技能和有限时间内分配行为、开辟路径、控制损失。理解了这一点,就不难理解为什么很多游戏开发者会把 Lemmings 当成状态机、寻路、碰撞检测和资源调度的绝佳教学案例。

本文不打算还原原版素材,而是从实现角度做一个可运行的 Lemmings 行为框架。主角是 Python 和 Pygame,核心内容围绕:旅鼠的基础状态机、像素地形碰撞、挖掘/搭桥等技能如何修改地形、如何用固定时间步做批量关卡回放。读完之后,你既能跑通一个最小 Demo,也能把里面的状态机思维迁移到批量任务调度、仿真 Agent、机器人路径规划等场景。

1. Lemmings 核心玩法本质与能力速览

先给一张“规格表”,帮助你判断这套内容适不适合自己。这里讨论的是经典《Lemmings》玩法的编程实现,不是官方游戏本体,也不包含任何受版权保护的图片和音效资产。

维度说明
玩法类型多人控制、资源有限的即时解谜
行为实体旅鼠:每个个体按状态机独立行动,不需要玩家逐个点击操纵
核心资源各类技能数量,例如挖掘、搭桥、炸开地形、撑伞、加固等
关卡目标在时间限制内,让足够数量的旅鼠到达出口
地形模型像素级二维网格,技能会动态修改地形数据
主要算法有限状态机、像素碰撞检测、寻路/打通路径、技能调度
关键技术点同屏多对象更新、地形与实体交互、确定性回放
适合学习人群游戏开发新手、AI Agent 入门、想理解批量仿真的人
不适合的场景追求高质量美术、复杂物理、大型商业玩法原型

从工程角度看,Lemmings 的价值模型可以简化成三个问题:

  1. 如何让成百上千个单位不靠脚本硬编码,也能“看起来有智力地行动”?
  2. 如何设计一套技能系统,让玩家通过改变地形来间接影响单位运动?
  3. 又如何保证同样的操作序列能在批量仿真里稳定复现?

后面所有内容都会围绕这三条线展开。

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, direction=1) 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 状态迁移优先级

一个旅鼠不能同时又是挖掘者又是建造者,所以需要在每一帧更新时先确认状态优先级。一种可行的做法是:

  1. 如果当前不是 walk/fall 等基础状态,直接执行技能行为,不执行普通移动。
  2. 如果玩家给这只旅鼠分配了技能,则在下一个可执行帧把技能写入“尚未开始的技能状态”。
  3. 如果行为条件不满足,例如没有地表可以挖掘,则技能失效,回到普通状态。

下面用伪代码描述状态机更新层:

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", []), key=lambda 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 把一个完整方案固定下来。每完成一步都是独立实验,中间出问题的概率会低很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询