用 aider 以对话方式从零构建 Pygame Pong:SEARCH/REPLACE 编辑格式与迭代式游戏开发的完整实战
【免费下载链接】Qwen3-CoderQwen3-Coder is the code version of Qwen3, the large language model series developed by Qwen team.项目地址: https://gitcode.com/GitHub_Trending/co/Qwen3-Coder
在本文中,我们基于 pong.md 这份真实聊天记录,完整复现"用自然语言驱动 aider 从零开发一个 Pygame Pong 游戏"的全过程:从创建pong_game.py基础骨架、加入 Paddle/Ball 类与游戏循环,到逐步调整球速、拍子尺寸与颜色,甚至实现"每次反弹加速 10%"的游戏机制。通过逐条拆解聊天中的SEARCH/REPLACE编辑块(edit block),并结合仓库内 editblock_coder.py 的解析与应用实现,读者将掌握 aider 的核心编辑协议、自动 git 提交流程,以及如何用纯对话完成从原型到可玩游戏的迭代开发。
聊天记录背景:aider 是怎么"看懂"编辑请求的
在进入 Pong 案例之前,先理解这份 transcript 的运行机制。根据 examples/README.md 的说明,这些聊天记录展示了 aider 的实际工作方式:
- 每当 LLM 给出一个代码修改建议,aider 会自动将其应用到源文件;
- 应用完成后,aider 会使用描述性 commit message 自动提交到 git;
- LLM 只能看到并编辑"已加入聊天会话"的文件,用户通过命令行参数或聊天内的
/add命令添加文件;当 LLM 主动要求查看某个文件时,aider 会先征得用户许可(transcript 中能看到文件被加入或移出会话的通知)。
transcript 的排版约定也很明确:>前缀是 aider 工具自身的输出,####是用户输入的聊天消息,蓝色字体的普通文本是 LLM 的回复,其中常包含彩色标注的 edit block 来指明代码改动。
edit block 的格式非常关键,它在 editblock_prompts.py 的 system reminder 中有严格定义,基本骨架如下:
文件路径(独占一行) <<<<<<< SEARCH 现有文件中的一段连续代码(必须逐字符精确匹配) ======= 要替换进去的新代码 >>>>>>> REPLACE关于这一格式,源码给出的规则包括:SEARCH部分必须与现有文件内容逐字符精确匹配(包括所有注释、docstring、缩进);SEARCH/REPLACE 块会替换所有匹配出现的位置;块要保持简洁,大改动应拆成一系列小块;创建新文件时使用空 SEARCH 段加完整 REPLACE 段——这正是 Pong 案例中第一个 edit block 的做法。
第一步:从零创建 pong_game.py 基础骨架
用户的第一条指令是:
can you make a simple game of pong with pygame?
aider 先给出了开发步骤拆解(安装 Pygame、初始化窗口、创建 Paddle 与 Ball 类、初始化对象、游戏循环、输入处理、碰撞与计分、绘制、记分、显示分数),然后通过一个"新文件"类型的 edit block 创建了pong_game.py——注意 SEARCH 段为空,这对应源码中"新文件"的约定:
pong_game.py <<<<<<< ORIGINAL ======= import pygame import sys # Initialize Pygame pygame.init() # Set up the game window screen_width = 640 screen_height = 480 screen = pygame.display.set_mode((screen_width, screen_height)) pygame.display.set_caption("Pong") # Game loop running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False # Update game objects # Draw game objects # Update the display pygame.display.flip() # Quit Pygame pygame.quit() sys.exit() >>>>>>> UPDATED(注:transcript 原文中此处的分隔符写作ORIGINAL/UPDATED,与规范中的SEARCH/REPLACE等价,源码 editblock_coder.py 中定义了HEAD = "<<<<<<< SEARCH"、DIVIDER = "======="、UPDATED = ">>>>>>> REPLACE"三个标记。)
随后 transcript 记录了 aider 的关键交互流程:
Allow creation of new file pong_game.py? y Add pong_game.py to git? y Applied edit to pong_game.py Commit 14799cb aider: Added basic Pygame initialization and game loop structure to
pong_game.py.
这展示了一个重要事实:创建新文件与加入 git 都需要用户显式确认(输入y),这与 README 中"LLM 只能编辑已加入会话的文件"的原则一致——aider 在此处请求了用户许可后才真正落盘并提交。
第二步:加入 Paddle / Ball 类与完整游戏循环
用户说 "go ahead",aider 在同一个文件中继续追加代码。首先在初始化段之后插入类定义与random导入:
pong_game.py <<<<<<< ORIGINAL import pygame import sys # Initialize Pygame pygame.init() # Set up the game window screen_width = 640 screen_height = 480 screen = pygame.display.set_mode((screen_width, screen_height)) pygame.display.set_caption("Pong") ======= import pygame import sys import random # Initialize Pygame pygame.init() # Set up the game window screen_width = 640 screen_height = 480 screen = pygame.display.set_mode((screen_width, screen_height)) pygame.display.set_caption("Pong") # Paddle and Ball classes class Paddle(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() self.width = 10 self.height = 60 self.image = pygame.Surface((self.width, self.height)) self.image.fill((255, 255, 255)) self.rect = self.image.get_rect() self.rect.x = x self.rect.y = y def move(self, dy): if self.rect.y + dy >= 0 and self.rect.y + dy <= screen_height - self.height: self.rect.y += dy class Ball(pygame.sprite.Sprite): def __init__(self): super().__init__() self.width = 10 self.height = 10 self.image = pygame.Surface((self.width, self.height)) self.image.fill((255, 255, 255)) self.rect = self.image.get_rect() self.rect.x = screen_width // 2 - self.width // 2 self.rect.y = screen_height // 2 - self.height // 2 self.dx = random.choice([-1, 1]) * 4 self.dy = random.choice([-1, 1]) * 4 def update(self): self.rect.x += self.dx self.rect.y += self.dy # Collision with top and bottom if self.rect.y <= 0 or self.rect.y >= screen_height - self.height: self.dy = -self.dy # Scoring if self.rect.x <= 0 or self.rect.x >= screen_width - self.width: self.reset() def reset(self): self.rect.x = screen_width // 2 - self.width // 2 self.rect.y = screen_height // 2 - self.height // 2 self.dx = random.choice([-1, 1]) * 4 self.dy = random.choice([-1, 1]) * 4 def collide_with(self, paddle): if self.rect.colliderect(paddle.rect): self.dx = -self.dx >>>>>>> UPDATED注意这里只替换了文件的开头部分(文件头与窗口初始化),文件后半部分的游戏循环保持不动——这正是 SEARCH/REPLACE 机制的典型用法:SEARCH 段精确锁定一小段上下文,REPLACE 段将其替换为更长的内容。
接着第二个 edit block 替换游戏循环占位符,加入对象初始化、键盘输入(W/S 控制左拍,方向键控制右拍)、球体更新与碰撞、绘制逻辑以及 16ms 帧延迟:
pong_game.py <<<<<<< ORIGINAL # Game loop running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False # Update game objects # Draw game objects # Update the display pygame.display.flip() ======= # Initialize paddles and ball left_paddle = Paddle(10, screen_height // 2 - 30) right_paddle = Paddle(screen_width - 20, screen_height // 2 - 30) ball = Ball() # Game loop running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False # Handle user input keys = pygame.key.get_pressed() if keys[pygame.K_w]: left_paddle.move(-5) if keys[pygame.K_s]: left_paddle.move(5) if keys[pygame.K_UP]: right_paddle.move(-5) if keys[pygame.K_DOWN]: right_paddle.move(5) # Update game objects ball.update() ball.collide_with(left_paddle) ball.collide_with(right_paddle) # Draw game objects screen.fill((0, 0, 0)) screen.blit(left_paddle.image, left_paddle.rect) screen.blit(right_paddle.image, right_paddle.rect) screen.blit(ball.image, ball.rect) # Update the display pygame.display.flip() pygame.time.delay(16) >>>>>>> UPDATED两个 edit block 全部应用成功后,aider 再次提交:
Applied edit to pong_game.py Applied edit to pong_game.py Commit b20bcee aider: Added Paddle and Ball classes, handled user input, updated ball position, drew objects, and handled scoring.
到这一步,一个可玩的基础 Pong 游戏已经诞生。从实现上看,Ball.update()负责位移与上下边界反弹,collide_with()用pygame.Rect.colliderect做 AABB 碰撞检测并反向水平速度,Paddle.move()则通过边界检查把拍子限制在窗口内——这些细节全部由 LLM 一次生成,用户无需手写任何代码。
第三步:微调游戏手感——降低球速
用户反馈 "the ball moves way too fast",aider 通过一个极小的 edit block 把球的初速度从 4 降到 2:
pong_game.py <<<<<<< ORIGINAL self.dx = random.choice([-1, 1]) * 4 self.dy = random.choice([-1, 1]) * 4 ======= self.dx = random.choice([-1, 1]) * 2 self.dy = random.choice([-1, 1]) * 2 >>>>>>> UPDATEDApplied edit to pong_game.py Commit f4ceed4 aider: Reduced ball speed by half.
这个改动同时影响Ball.__init__与Ball.reset(两处代码相同,SEARCH/REPLACE 块会替换所有匹配出现的位置——这正是 editblock_prompts.py 中"will replaceallmatching occurrences"规则的实际体现)。aider 也在回复中提示用户可自行微调数值以获得更慢的效果,体现了它"尊重用户控制权"的设计。
第四步:视觉定制——加大拍子并改成红蓝配色
用户提出 "make the paddles a bit bigger? and make one paddle red and one blue?",aider 分两次改动完成:
先修改Paddle类,尺寸从 10x60 增大到 15x80,并依据拍子所在半区(x 坐标是否小于窗口中线)设置默认颜色——右半区默认蓝色:
pong_game.py <<<<<<< ORIGINAL self.width = 10 self.height = 60 self.image = pygame.Surface((self.width, self.height)) self.image.fill((255, 255, 255)) ======= self.width = 15 self.height = 80 self.image = pygame.Surface((self.width, self.height)) self.color = (255, 255, 255) if x < screen_width // 2 else (0, 0, 255) self.image.fill(self.color) >>>>>>> UPDATED再单独把左拍设为红色(同时微调其初始 y 坐标以适配新尺寸):
pong_game.py <<<<<<< ORIGINAL left_paddle = Paddle(10, screen_height // 2 - 30) ======= left_paddle = Paddle(10, screen_height // 2 - 40) left_paddle.color = (255, 0, 0) left_paddle.image.fill(left_paddle.color) >>>>>>> UPDATEDApplied edit to pong_game.py Applied edit to pong_game.py Commit 2c645c9 aider: Changed paddle size and color, and set left paddle to red.
这里有两个值得注意的细节:一是对Paddle类的改动会自动作用于左右两个拍子(因为它们共用同一个类),二是 aider 用了两个独立的 edit block 来分别处理"类定义"与"实例初始化"两个位置,验证了 prompts 中"将大改动拆分为一系列小块"的建议。
第五步:游戏机制增强——每次反弹加速 10%
最后一个需求颇具游戏设计意味:
each time the ball bounces off the paddle, can you make it go 10% faster?
aider 修改Ball.collide_with(),把原本的纯反向改为反向并放大 1.1 倍:
pong_game.py <<<<<<< ORIGINAL def collide_with(self, paddle): if self.rect.colliderect(paddle.rect): self.dx = -self.dx ======= def collide_with(self, paddle): if self.rect.colliderect(paddle.rect): self.dx = -self.dx * 1.1 self.dy *= 1.1 >>>>>>> UPDATEDApplied edit to pong_game.py Commit 693faf4 aider: Increased ball speed by 10% in both x and y directions each time it bounces off a paddle.
这一改动使水平方向在反转的同时放大,垂直方向直接放大,球体随拍击次数逐次加速,为游戏引入了逐步上升的难度曲线——全部通过一句自然语言需求完成,无需用户自己定位方法或推导数学表达式。
源码解析:edit block 是如何被解析与应用的
看完整个案例,我们来追一遍 edit block 在源码中的生命周期,这能帮助读者理解"为什么 SEARCH 必须精确匹配"以及"匹配失败时会发生什么"。
解析阶段。在 editblock_coder.py 的find_original_update_blocks()中,回复文本按行扫描:遇到<<<<<<< SEARCH标记后收集 SEARCH 段直至=======,再收集 REPLACE 段直至>>>>>>> REPLACE,并以(文件名, SEARCH 文本, REPLACE 文本)三元组产出。文件名通过find_filename()从 edit block 上方最多 3 行中回溯查找,支持精确匹配、基于Path的 basename 匹配、以及difflib.get_close_matches的模糊匹配(cutoff=0.8)。
应用阶段。apply_edits()遍历解析出的 edit block,对每个 block 调用do_replace():如果目标文件不存在且 SEARCH 为空,则创建新文件(对应案例第一步);否则调用replace_most_similar_chunk()在文件中查找 SEARCH 文本并替换。查找按"完美匹配 → 容忍统一缩进偏差 → 容忍首个空行 → 支持...省略号(对应try_dotdotdots())"的优先级逐级降级容错。如果 SEARCH 段在文件中找不到匹配,aider 会返回详细的错误报告,指出该 block 匹配失败,并用find_similar_lines()基于SequenceMatcher相似度(阈值 0.6)给出"你是否想匹配这些行"的提示。
测试验证。仓库中的 test_editblock.py 对这一整套协议做了系统测试,例如test_find_original_update_blocks验证从带围栏文本中正确提取("foo.txt", "Two\n", "Tooooo\n");test_find_filename验证文件名可从# file1.py、file1_py等变体中正确解析;test_find_original_update_blocks_missing_filename与test_find_original_update_blocks_unclosed则分别验证缺少文件名、缺少>>>>>>> REPLACE结束标记时抛出ValueError。这些测试从侧面印证了案例中每个 edit block 之所以能被"Applied edit"成功,是因为 SEARCH 段与文件内容的精确匹配得到了严格保证。
迭代成果:最终的 pong_game.py
将五个 edit block 依次应用到初始骨架后,最终可运行的游戏代码如下(累计了全部定制:球速 2、拍子 15x80、左红右蓝、反弹加速 10%):
import pygame import sys import random # Initialize Pygame pygame.init() # Set up the game window screen_width = 640 screen_height = 480 screen = pygame.display.set_mode((screen_width, screen_height)) pygame.display.set_caption("Pong") # Paddle and Ball classes class Paddle(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() self.width = 15 self.height = 80 self.image = pygame.Surface((self.width, self.height)) self.color = (255, 255, 255) if x < screen_width // 2 else (0, 0, 255) self.image.fill(self.color) self.rect = self.image.get_rect() self.rect.x = x self.rect.y = y def move(self, dy): if self.rect.y + dy >= 0 and self.rect.y + dy <= screen_height - self.height: self.rect.y += dy class Ball(pygame.sprite.Sprite): def __init__(self): super().__init__() self.width = 10 self.height = 10 self.image = pygame.Surface((self.width, self.height)) self.image.fill((255, 255, 255)) self.rect = self.image.get_rect() self.rect.x = screen_width // 2 - self.width // 2 self.rect.y = screen_height // 2 - self.height // 2 self.dx = random.choice([-1, 1]) * 2 self.dy = random.choice([-1, 1]) * 2 def update(self): self.rect.x += self.dx self.rect.y += self.dy # Collision with top and bottom if self.rect.y <= 0 or self.rect.y >= screen_height - self.height: self.dy = -self.dy # Scoring if self.rect.x <= 0 or self.rect.x >= screen_width - self.width: self.reset() def reset(self): self.rect.x = screen_width // 2 - self.width // 2 self.rect.y = screen_height // 2 - self.height // 2 self.dx = random.choice([-1, 1]) * 2 self.dy = random.choice([-1, 1]) * 2 def collide_with(self, paddle): if self.rect.colliderect(paddle.rect): self.dx = -self.dx * 1.1 self.dy *= 1.1 # Initialize paddles and ball left_paddle = Paddle(10, screen_height // 2 - 40) left_paddle.color = (255, 0, 0) left_paddle.image.fill(left_paddle.color) right_paddle = Paddle(screen_width - 20, screen_height // 2 - 30) ball = Ball() # Game loop running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False # Handle user input keys = pygame.key.get_pressed() if keys[pygame.K_w]: left_paddle.move(-5) if keys[pygame.K_s]: left_paddle.move(5) if keys[pygame.K_UP]: right_paddle.move(-5) if keys[pygame.K_DOWN]: right_paddle.move(5) # Update game objects ball.update() ball.collide_with(left_paddle) ball.collide_with(right_paddle) # Draw game objects screen.fill((0, 0, 0)) screen.blit(left_paddle.image, left_paddle.rect) screen.blit(right_paddle.image, right_paddle.rect) screen.blit(ball.image, ball.rect) # Update the display pygame.display.flip() pygame.time.delay(16) # Quit Pygame pygame.quit() sys.exit()运行方式为:
pip install pygame python pong_game.py案例总结:这套工作流给开发者带来的启示
回看整段对话,从第一条需求到最终可玩的定制版 Pong,共经历 5 轮交互、6 次文件编辑、5 次自动 git 提交(14799cb、b20bcee、f4ceed4、2c645c9、693faf4),每一轮都遵循"用户提需求 → LLM 给出 SEARCH/REPLACE edit block → aider 精确应用 → 自动提交"的闭环。这个案例至少说明三点:
- edit block 是 aider 与模型之间的稳定协议:精确匹配的 SEARCH 段保证了编辑的可预期性,即使匹配失败,aider 也会给出清晰的诊断与相似行提示(见 editblock_coder.py 的错误处理逻辑);
- 粒度化提交天然形成开发历史:每个需求对应一次语义清晰的 commit,方便回滚与回溯,这得益于 base_coder.py 中对 git 操作的内建支持;
- 自然语言可以承载"设计意图"而不只是"代码操作":从"球太快"到"每次反弹加速 10%",用户描述的是体验与目标,模型负责将其翻译为具体数值与逻辑改动。
如果你想继续深入了解 edit block 的完整语法规范、更多示例 transcript(如 Flask 应用、多文件复杂改动、测试用例生成等),可以继续阅读 examples/README.md 中列出的其他对话记录,或直接查看 editblock_prompts.py 中写给模型的完整规则。
【免费下载链接】Qwen3-CoderQwen3-Coder is the code version of Qwen3, the large language model series developed by Qwen team.项目地址: https://gitcode.com/GitHub_Trending/co/Qwen3-Coder
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考