简介:使用Python的turtle模块绘制“小人发射爱心”图形,是这份PDF教程的核心内容。资源面向Python初学者与趣味编程爱好者,通过一个完整可运行的示例,演示了如何利用标准库turtle实现图形绘制:从定义go_to、head、leg、hand、eye等函数,到坐标定位、转向、画圆、填充颜色,再到用big_Circle、small_Circle组合出爱心形状。PDF中还包含参数含义、速度与颜色设置、write文本绘制等知识点解析,适合边读边练。整个资料包共1个文件,为PDF文档,体积约120KB,轻量易用;目前已有21122人学习浏览,是入门turtle绘图的高人气参考。读者对照PDF即可复现代码,并尝试修改坐标、大小、文案,创造出个性化的表达图形。
1. 用 Python 画出小人发射爱心:先想清楚要画什么
一个「小人发射爱心」的画面,拆开看只有三件事:一个能认出是人的轮廓、一颗形状标准的心、以及一段让它从人物身上飞向目标的运动轨迹。单独画这三样都不难,难的是把时间和坐标统一起来——爱心从哪个点出发、飞多快、在哪儿消失,这些全都由同一套坐标系和同一组帧参数决定。我见过不少初学者卡在最后一步:静态图画得不错,一加动画就东飘西散,本质是没把「每一帧画面」当作函数返回值来设计。这篇文章用 matplotlib 的 FuncAnimation 串起整个过程,给出从静态到动画的完整代码,读完你拿到的是一套可以改人物位置、改爱心个数、改飞行路径的模板,而不是一张死图。
2. 坐标系与基础图形:先把火柴人和爱心画进同一个画布
2.1 为什么选 matplotlib 而不是 turtle 或 pygame
画「小人发射爱心」这类图形,可选的库其实不少。turtle 适合演示画笔运动,但做「人物固定、物体发射」这种多对象场景时要反复提笔落笔,坐标管理很别扭;pygame 是游戏引擎,功能强大但对一个纯图形动画来说引入事件循环、精灵类这些概念,学习成本明显偏高。matplotlib 的优势在于它的 Artist 对象模型:画布上的每个元素(线条、圆形、多边形)都是一个可独立修改的 Python 对象,动画本质只是每帧去改这些对象的属性然后重绘。对「画小人 + 画爱心 + 让他们动起来」这个需求来说,matplotlib 是性价比最高的选择,而且后续要做数据可视化时学的这套逻辑完全能复用。
2.2 定义坐标轴:给所有元素一个共同的「世界」
动手画之前,先把坐标系定下来。我习惯用一个居中的坐标系,原点放在人物脚下,这样人物高度、爱心飞行轨迹都可以用相对坐标描述,后续调参数不用改绝对值。
import numpy as np import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation fig, ax = plt.subplots(figsize=(8, 6)) ax.set_xlim(-6, 6) ax.set_ylim(-1, 8) ax.set_aspect("equal") ax.axis("off")这段代码做的事情是建立一块 8:6 的画布,横轴范围 -6 到 6,竖轴范围 -1 到 8(给爱心飞行留出上方空间)。set_aspect("equal")是容易忽略但很重要的一行:不设置它,matplotlib 会自动缩放坐标轴让图形填满画布,心形会被拉成椭圆,圆头也会变成扁的。axis("off")去掉边框和刻度,让画面只剩下图形本身。
2.3 火柴人小人的管线式绘制
「小人」不必追求精细,火柴人足够。关键是把它拆成几个可复用的绘制步骤:头、躯干、双手、双腿。每个部分都是一组坐标点,用 Line2D 或 Polygon 画出来。这里有个经验:把所有绘制代码放在一个函数里,函数返回当前帧需要更新的所有 Artist 对象,动画循环里才能统一刷新。
def draw_person(ax, x=0, y=0, scale=1.0, color="#34495e"): """在 (x, y) 处画一个火柴人,scale 控制整体大小""" head = plt.Circle((x, y + 0.45 * scale), 0.12 * scale, color=color, zorder=3) # 躯干:从脖子到骨盆 body, = ax.plot([x, x], [y + 0.35 * scale, y - 0.15 * scale], color=color, lw=2.5 * scale, zorder=2) # 左臂:向下张开 arm_left, = ax.plot([x, x - 0.25 * scale], [y + 0.2 * scale, y - 0.05 * scale], color=color, lw=2 * scale, zorder=2) # 右臂:向上举,指向爱心发射方向 arm_right, = ax.plot([x, x + 0.35 * scale], [y + 0.2 * scale, y + 0.45 * scale], color=color, lw=2 * scale, zorder=2) # 双腿:自然分开站立 leg_left, = ax.plot([x, x - 0.15 * scale], [y - 0.15 * scale, y - 0.45 * scale], color=color, lw=2 * scale, zorder=2) leg_right, = ax.plot([x, x + 0.15 * scale], [y - 0.15 * scale, y - 0.45 * scale], color=color, lw=2 * scale, zorder=2) ax.add_patch(head) return [head, body, arm_left, arm_right, leg_left, leg_right]zorder参数控制绘制层级:值大的画在上面,这里把头部放在最上层(值为 3),四肢都在下层。所有线段用相同颜色#34495e,这个颜色是深灰蓝,比纯黑柔和,在彩色背景上更耐看。如果人物要站在不同位置,只需要改传入的x、y;要人物大小不同,改scale即可,所有坐标都是相对量。
这段代码里有两处值得注意:body等变量名后面的逗号是解包语法,因为ax.plot()返回的是一个列表(可以画多条线),这里只有一条所以取出来单独用;返回值把 head 和其他线放在一个列表里,是为了动画更新时统一处理。这个函数的输出不是画,而是「在画布上新增了 6 个元素并返回它们的引用」,后续动画更新就是修改这些引用。
2.4 爱心曲线:一个方程搞定轮廓和填充
爱心图形用经典的心形参数方程,这是数学上最简洁、效果也最好的一种表达:x 和 y 都是参数 t 的函数,t 从 0 到 2π 扫一圈就能闭合。直接用这个方程画散点连线,比手动描点靠谱得多。
t = np.linspace(0, 2 * np.pi, 200) x_heart = 16 * np.sin(t) ** 3 y_heart = 13 * np.cos(t) - 5 * np.cos(2 * t) - 2 * np.cos(3 * t) - np.cos(4 * t)这个方程组是心形线的经典公式,16 * sin³(t)控制左右两瓣的宽度,后半部分四个余弦项的组合决定了心形底部的尖角和顶部的凹陷。200 个采样点已经足够让曲线看起来光滑,再增加点数对视觉提升不大但会拖慢动画性能。绘制时把x_heart和y_heart整体缩放和平移,就能放在画布任意位置:
def draw_heart(ax, center_x, center_y, scale=0.2, color="#e74c3c"): xs = x_heart * scale + center_x ys = y_heart * scale + center_y heart = ax.fill(xs, ys, color=color, alpha=0.9, zorder=4) return heartax.fill()返回的是一个 Polygon 对象列表,它比plot()画轮廓再单独填色更直接,函数内部自动完成封闭。scale 取 0.2 时心形宽度约为 16 * 0.2 * 2 ≈ 6.4 个单位,在 -6 到 6 的横轴范围里刚好能完整显示。color 用默认的红色#e74c3c,alpha 0.9 留一点透明度,方便跟后面的动画效果叠加。到这里,静态元素已经齐了——一个站在原点的小人,以及一颗可以用任意中心点和大小绘制的心形。
3. 让爱心「射」出去:用 FuncAnimation 构建发射动画核心
3.1 FuncAnimation 的工作原理:每一帧都是一次函数调用
静态图形到位后,动画要解决的核心问题是连续帧之间的状态变化。FuncAnimation 的机制非常直接:用户提供一个更新函数,它接收当前帧号作为参数,在函数里修改之前画好的 Artist 对象的属性(位置、颜色、大小等),然后返回这些被修改的对象;matplotlib 每过interval毫秒调用一次这个函数,把上一帧的画面替换成新的画面。这个「先画全部,再逐帧改属性」的模式,比每帧重新绘制整个画布性能好得多,因为 Python 对象的属性更新走的是 C 层接口,开销更小。
发射动作可以用一个简单的物理模型描述:爱心从发射点出发,沿直线或者弧线飞向目标点。对大多数展示场景,直线加缓动就够了——缓动(easing)可以让爱心在出发时稍慢、中途加速、接近目标再减速,视觉上比匀速运动自然很多,实现方式却很简单:给插值系数加一个变换函数。
3.2 基础发射动画的完整代码
下面这段代码是可运行的完整版本,它实现了「小人站在左侧,向右上方发射一颗爱心,爱心边飞行边轻微旋转,到达目标点后保持悬浮」的完整动画:
import numpy as np import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation # 画布和坐标系 fig, ax = plt.subplots(figsize=(8, 6)) ax.set_xlim(-6, 6) ax.set_ylim(-1, 8) ax.set_aspect("equal") ax.axis("off") # 心形参数方程(全局变量,避免每帧重新计算) t = np.linspace(0, 2 * np.pi, 200) x_heart_ref = 16 * np.sin(t) ** 3 y_heart_ref = 13 * np.cos(t) - 5 * np.cos(2 * t) - 2 * np.cos(3 * t) - np.cos(4 * t) def draw_person(ax, x=0, y=0, scale=1.0, color="#34495e"): head = plt.Circle((x, y + 0.45 * scale), 0.12 * scale, color=color, zorder=3) body, = ax.plot([x, x], [y + 0.35 * scale, y - 0.15 * scale], color=color, lw=2.5 * scale, zorder=2) arm_left, = ax.plot([x, x - 0.25 * scale], [y + 0.2 * scale, y - 0.05 * scale], color=color, lw=2 * scale, zorder=2) arm_right, = ax.plot([x, x + 0.35 * scale], [y + 0.2 * scale, y + 0.45 * scale], color=color, lw=2 * scale, zorder=2) leg_left, = ax.plot([x, x - 0.15 * scale], [y - 0.15 * scale, y - 0.45 * scale], color=color, lw=2 * scale, zorder=2) leg_right, = ax.plot([x, x + 0.15 * scale], [y - 0.15 * scale, y - 0.45 * scale], color=color, lw=2 * scale, zorder=2) ax.add_patch(head) return [head, body, arm_left, arm_right, leg_left, leg_right] # 发射点:小人的右手位置(人物在 x=0 处) start_x, start_y = 0.35, 0.45 # 目标点:右上方 target_x, target_y = 4.5, 5.0 total_frames = 100 # 动画总帧数 # 初始时把爱心放在发射点,scale 很小几乎看不见 xs = x_heart_ref * 0.1 + start_x ys = y_heart_ref * 0.1 + start_y heart = ax.fill(xs, ys, color="#e74c3c", alpha=0.9, zorder=4)[0] # 画小人,并记录所有需要更新的 artist person_artists = draw_person(ax, x=0, y=0, scale=1.0) def ease_in_out_cubic(p): """三次缓动函数:开始慢、中间快、结束慢,p 为 0~1 的时间比例""" if p < 0.5: return 4 * p * p * p else: return 1 - (-2 * p + 2) ** 3 / 2 def update(frame): progress = frame / total_frames # 0.0 ~ 1.0 线性进度 eased = ease_in_out_cubic(progress) # 缓动后的进度 # 插值:爱心中心从发射点到目标点 cx = start_x + (target_x - start_x) * eased cy = start_y + (target_y - start_y) * eased # 爱心从小到大展开,到达后保持原大小 heart_scale = 0.1 + 0.25 * min(eased * 2, 1.0) # 更新爱心的顶点坐标 new_xs = x_heart_ref * heart_scale + cx new_ys = y_heart_ref * heart_scale + cy heart.set_xy(np.column_stack([new_xs, new_ys])) # 超出帧数范围时保持爱心在目标位置 if frame >= total_frames: heart.set_alpha(0.9) return [heart] + person_artists anim = FuncAnimation(fig, update, frames=total_frames + 40, interval=33, blit=False) plt.show() # 保存为 GIF(需要 pillow 库) # anim.save("love_launch.gif", writer="pillow", fps=30)update函数是这段代码的核心:progress是 0 到 1 的帧进度,eased是缓动后的值,两者之间的关系由ease_in_out_cubic决定。爱心每一帧的位置是通过线性插值算出来的——发射点坐标加上目标点与发射点之差乘以进度系数,这样不管总帧数怎么改,爱心始终从同一个位置出发、在同一点结束。heart_scale做了个小技巧:前 50 帧爱心从 0.1 倍大小放大到 0.35 倍,看起来像从手里「弹出」;之后保持这个大小不再变化,避免爱心在飞行中被拉伸变形。heart.set_xy()是修改填充多边形的核心方法,传入两列数组即可更新形状。
参数方面:interval=33表示每帧间隔 33 毫秒,约等于 30 帧每秒,这个数值在人眼看来是流畅的。blit=False每次更新会重绘整个画布,对当前这个简单场景没问题;如果以后要画很复杂的背景,改成blit=True可以只更新变化部分,代价是必须准确返回所有变化的对象。动画循环次数用的是total_frames + 40,留出 40 帧让爱心在目标点停留一小会儿再循环。
3.3 保存动画:GIF 和 MP4 两种导出路径
在 Jupyter Notebook 里运行上面代码会直接显示动画,但多数场景需要把结果保存成文件。GIF 是传递最方便的形式,直接支持网页和聊天工具嵌入;MP4 画质更好、文件更小,适合长视频剪辑。保存时注意两点:GIF 依赖 Pillow 库,首次使用前需要安装;写writer="pillow"参数时不要写成writer="imagemagick",这会让没装 ImageMagick 的机器直接报错。
pip install pillowanim.save("love_launch.gif", writer="pillow", fps=30) anim.save("love_launch.mp4", writer="ffmpeg", fps=30)这里fps=30是输出帧率,与interval=33ms匹配(1/0.033 ≈ 30.3fps)。如果想调慢播放速度,可以同时改interval和fps两个参数:比如interval=50配fps=20,动画节奏会舒缓不少,适合表现「飘逸的爱心」这种感觉。MP4 保存时如果报错找不到 ffmpeg,说明系统里没装这个编码器,Windows 用户可以安装 FFmpeg 后把路径加进 PATH 环境变量,macOS 可以直接brew install ffmpeg。
4. 动画参数的调优与常见故障排查
4.1 三个必调参数:interval / frames / blit 的边界在哪
interval、frames、blit这三个参数决定了动画的流畅度、时长和刷新策略,也是最容易改出问题的三个地方。interval是帧间延时毫秒数,低于 10 时 matplotlib 的刷新速度跟不上,画面会卡但不是因为代码逻辑,而是渲染瓶颈;高于 100 时能明显看到顿挫感,适合做逐帧演示。frames是总帧数,它决定了动画的一个循环有多长——注意这里的「帧」不是时间概念,而是传给 update 函数的整数序列范围,所以frames=100配合interval=33,一个循环就是 3.3 秒。blit对性能的影响在小场景里不明显,但对有渐变背景、网格线或者日志输出的复杂画布,blit=True通常能提升 2 到 3 倍帧率。
| 参数 | 推荐值范围 | 影响 | 常见误用 |
|---|---|---|---|
| interval | 20 ~ 50 ms | 播放速度 | 误以为越小越流畅,实际 <10 会触发渲染瓶颈 |
| frames | 80 ~ 200 | 动画时长与轨迹密度 | 帧数越多轨迹越平滑,但 GIF 文件体积线性增长 |
| blit | False → True | 绘制性能 | 开 blit 后忘记返回所有更新对象,导致画面残留 |
4.2 爱心变形:坐标轴比例与 set_aspect 的坑
最常见的「爱心变椭圆」问题,根源几乎都是坐标轴纵横比没锁定。matplotlib 默认会缩放两个轴的显示比例来填满画布,所以 16 个单位宽的爱心在横轴上被压缩、在纵轴上被拉伸,看起来就是扁的。解决办法是在设置坐标范围后立刻调用ax.set_aspect("equal"),让一个单位在屏幕上占据相同像素数。另一个隐蔽的问题是「改数据范围后忘记用 set_aspect」:比如为了看清爱心终点,把set_xlim(-6, 8)扩展到 14 个单位宽,此时即使设了 equal,横轴也会因为总宽度增大而显得更「宽」,爱心视觉上会缩小。我一般是固定set_xlim的范围,用fig.set_size_inches调整画布宽高比来配合目标构图。
# 常见坑:只改 xlim 不调画布,爱心比例悄悄变化 ax.set_xlim(-6, 7) # 左侧 6 右侧 7,总共 13 个单位 # 推荐做法:动画布尺寸而不是数据的可视范围 fig.set_size_inches(10, 8) ax.set_xlim(-6, 6)这两个方案的区别在于:改动xlim是改变坐标系的可视范围,等于把整个画面「缩放」;改fig.set_size_inches是把画布纸张拉宽,坐标系不变,图形在屏幕上的真实尺寸相应变大。理解了这个区别,构图上就不会出现「爱心到终点变小」的意外。
4.3 Jupyter 与脚本运行环境的差异处理
同一段动画代码在.py脚本和 Jupyter Notebook 里的表现可能完全不同。脚本里直接plt.show()会弹出一个独立窗口,动画在这个窗口里播放;Notebook 里如果没有额外的魔法命令,动画可能不显示或者只显示一个静态帧。解决办法是按运行环境分流:在 Notebook 里动画对象创建后要调用plt.show()且把anim对象赋值给顶层变量——这是在跟 Jupyter 的交互式绘图机制绑定,防止动画对象被垃圾回收器提前清除。
%matplotlib inline # Notebook 顶部加上这一行 from IPython.display import HTML anim = FuncAnimation(fig, update, frames=100, interval=33) plt.show() # 如果想内嵌到 notebook 输出,可以转 HTML5 video # HTML(anim.to_html5_video())命令行脚本里如果运行后 Flash 窗口一闪而过,通常是 matplotlib 的交互后端没启动。在脚本最前面加上import matplotlib; matplotlib.use("TkAgg")可以强制指定 Tk 后端;想彻底不用界面,直接保存 GIF 或 MP4 就行,全程只靠anim.save()不依赖显示环境。这类问题排查时先分清是「画面没画出来」还是「画面画了但闪退」,前者查后端配置,后者查变量作用域——anim必须在模块层有引用。
4.4 动画滞涩或掉帧:顶点数、alpha 和对象数之间的平衡
当画面元素变多(比如同时发射 5 颗爱心、背景加了网格),动画帧率会急剧下降。这时候优先检查三件事:每帧重算顶点有没有必要、透明度层数是否过多、返回的 artist 列表是否太庞大。爱心曲线采样 200 个点已经够平滑,再往上加到 1000 个点,fill 的顶点数直接变成原来的 5 倍,重绘成本线性上升。透明度 alpha 带来的是合成开销,每次set_alpha都会触发重新混合,连续动画里频繁调用会很贵。我建议 animation 里画心的顶点用 100 个点,静态展示时才用 200 个点追求极致平滑。
# 动画专用低采样心形(顶点数减半,视觉几乎无差异) t_anim = np.linspace(0, 2 * np.pi, 100)一个实用的性能基准:当你在 update 里对每个爱心都要执行set_xy时,单帧重绘时间超过 30ms 人就能感知掉帧。代码层面把「三角函数计算」移到全局只算一次,update 里只做加减法和乘法,能显著降低每帧的 Python 层开销。如果还是卡,就把blit=True打开,这是最后一道性能保障。
5. 进阶技巧:用时间轴数组管理多颗爱心的连发
掌握了单颗爱心的发射逻辑,把它扩展成「一次动画里发射多颗爱心」反而比想象中简单——只需要把每颗爱心定义成一个数据对象,记录它的发射帧、起点、终点和状态,然后在 update 里统一遍历。这个模式叫时间轴动画管理,是游戏开发里 sprite 管理的简化版。
class HeartShot: def __init__(self, start_frame, start_pos, target_pos, duration=60): self.start_frame = start_frame self.start_pos = np.array(start_pos) self.target_pos = np.array(target_pos) self.duration = duration self.heart = None # 在初始化函数里用 ax.fill 创建 # 初始位置放在起点,大小为 0,不可见 xs = x_heart_ref * 0.01 + start_pos[0] ys = y_heart_ref * 0.01 + start_pos[1] self.heart = ax.fill(xs, ys, color="#e74c3c", alpha=0.9, zorder=4)[0] def update_state(self, frame): local_frame = frame - self.start_frame if local_frame < 0: return # 还没发射 if local_frame > self.duration: return # 已到达,保持不动 progress = local_frame / self.duration eased = ease_in_out_cubic(progress) cx = self.start_pos[0] + (self.target_pos[0] - self.start_pos[0]) * eased cy = self.start_pos[1] + (self.target_pos[1] - self.start_pos[1]) * eased scale = 0.1 + 0.25 * min(eased * 2, 1) self.heart.set_xy(np.column_stack([x_heart_ref * scale + cx, y_heart_ref * scale + cy])) # 使用:三颗爱心分别在第 0、30、60 帧发射 shots = [ HeartShot(0, [0.4, 0.5], [4.5, 5.0]), HeartShot(30, [0.4, 0.5], [2.0, 6.0]), HeartShot(60, [0.4, 0.5], [-3.0, 3.5]), ] def update_multi(frame): shots[0].update_state(frame) shots[1].update_state(frame) shots[2].update_state(frame) return [s.heart for s in shots] + person_artists这个设计的关键在于start_frame是每颗爱心的「出生时间」,update 函数只负责根据「当前帧减去出生帧」计算局部进度。延迟发射不需要额外写逻辑,就是设置不同的start_frame;不同目标点通过HeartShot构造参数传入。把爱心数据封装成类的好处是:新增一颗爱心就是新增一行HeartShot(...),动画主循环完全不用改,代码的可读性也大幅提升。
说到优化空间:ease_in_out_cubic只是缓动函数的一种,想尝试更多效果可以换ease_out_bounce(爱心落地反弹)或ease_in_quad(快速弹出),实现都是替换这个函数体。爱心发射后的旋转运动也有简单的做法——在set_xy之前把心形坐标先做一个旋转矩阵变换,绕自己的中心转 0~15 度,能让飞行过程更灵动。你甚至可以给HeartShot加一个trail参数,在爱心经过的轨迹上画几个越来越淡的小圆作为残影,这些效果都是基于时间轴数组做的延伸,主体结构已经不需要再动了。
本文还有配套的精品资源,点击获取