1. 小海龟不止是儿童玩具:为什么我强烈推荐用turtle开启Python绘画之旅
如果你刚接触Python,或者已经学过几节课但总觉得没意思,那我建议你试试换个思路:别急着去啃那些枯燥的语法,先让代码画点看得见摸得着的东西出来。我自己在带零基础朋友入门的时候发现一个通病——第一课让他们print("Hello World"),第二课可能还有兴趣,到第三课讲变量和字符串拼接,很多人就开始打退堂鼓了。原因很简单:反馈太抽象。你在屏幕上敲出一行文字,屏幕上再冒出一行一模一样的文字,这种成就感顶多维持三分钟。但如果你输入几行代码,屏幕上真的慢慢画出了一栋带烟囱的小房子、一棵有枝丫的大树,那种“我真的在创造东西”的感觉是完全不一样的。
这也是为什么我一直觉得,Python自带的turtle库被严重低估了。很多人把它当成小学生学编程的玩具,但实际上,turtle库对成年人学习编程来说同样是一个极佳的工具。它不需要你事先理解复杂的图形学知识,也不用安装任何第三方依赖,只需要Python标准库就能运行。更妙的是,它会强制你建立一种“程序员的思维方式”:把一幅画拆解成无数的直线、曲线、角度和坐标,然后一步步用指令去执行。这个拆解和组合的过程,恰恰就是编程的核心。等你用turtle画完几幅作品再回头去看循环、函数、变量这些概念,会有一种豁然开朗的感觉——原来它们不是孤立的语法点,而是你刚才使用的每一个动作的抽象总结。
这篇文章我们就从零开始,一步步用代码画出你的第一幅艺术作品。我会先带你安装并验证环境,然后拆解turtle的核心绘图逻辑,接着写一栋带天空、草地、太阳和树木的彩色小房子,再进阶到用递归画一棵随机生长的分形树。整个过程不需要任何美术功底,你只需要能看懂坐标、角度和简单的Python语法就够了。
1.1 安装Python最容易忽略的一步:环境变量的坑
很多教程会默认你已经装好了Python,但实际教新手的过程中,我发现“明明装了Python却用不了”是最常见的起步障碍。问题大多出在安装时漏掉了一个关键选项:Add Python to PATH。
如果你去Python官网下载了安装包,双击运行后会在第一步看到一个很大的复选框列表,位于窗口最底部。你需要在“Add Python to PATH”前面打勾,再点Install Now。这个操作的意思是让系统在任何目录下都能认出python和pip这两个命令。如果不勾选,Python确实也装进去了,但你打开命令行输入python时,系统会一脸茫然地告诉你“不是内部或外部命令”,或者更离谱的,直接把Windows商店里那个假Python的下载页面弹出来。
已经装完但没勾选PATH也别急,不需要重装。打开系统设置,搜索“环境变量”,找到Path,在列表里加上两条路径:一条是Python的实际安装路径,比如C:\Users\你的用户名\AppData\Local\Programs\Python\Python312\,另一条是它下面的Scripts子目录。添加完记得关掉命令行窗口再重新打开,设置才会生效。
安装完成后,在命令行输入python --version,能看到版本号比如Python 3.12.1,就说明可以开始用了。turtle库是Python标准库的一部分,跟print、math一样,不需要额外pip install,只要你装的是官方版本,它天然就在那里。
1.2 验证阶段:屏幕上的第一只小海龟
环境准备好后,我建议先别急着写大段代码,而是用一个最小的验证脚本找到“手感”。打开你常用的编辑器,新建一个文件,比如叫hello_turtle.py,输入下面三行:
import turtle t = turtle.Turtle() turtle.done()第一行导入turtle模块,第二行创建了一只小海龟对象,命名叫t,第三行turtle.done()的作用是让窗口保持打开状态。很多新手在第一步就会踩坑:运行这个脚本后,画布窗口闪了一下就消失了,根本看不清里面有什么。原因就是你忘了加第三行。当Python执行完所有代码后,程序会正常结束,turtle创建的窗口也随之关闭。加上了turtle.done(),程序会一直监听画布上的事件,直到你手动关闭窗口为止。
我还见过另一种情况:代码明明写了done(),窗口依然不显示,或者隔了很久才弹出来。这多半是编辑器的问题,比如在VS Code的集成终端里运行tturtle,偶尔会因为终端缓冲问题导致窗口刷新不及时。这时候可以把终端切到“外部终端”运行,或者直接在系统命令行里执行python hello_turtle.py,基本都能解决。
顺利的话,你会看到一个白色的画布窗口,中间有一只黑色的小箭头模样的图标。别再觉得它只是图标了——它就是你即将指挥的画笔,你写的每一行绘图指令,都会以它为起点。
2. 先别急着画房子:海龟画图的底层逻辑是坐标加方向
用turtle画图,本质上是在回答三个问题:从哪个点开始?朝向哪个方向?走多远?这跟在纸上画画完全不同,因为你不能一上来就用曲线勾轮廓。你得像指挥一个严格执行命令的机器人一样,告诉它前进多少像素、左转多少度、抬笔还是落笔。整个过程非常机械,但也正是这种机械性,反过来逼迫你把一个模糊的视觉意象拆解成精确的逻辑序列。
如果你直接从网上复制一个画枫叶或小人的教程,即使代码跑通了,你大概率也是一头雾水,下次换个图形照样不会画。所以先花点时间把海龟的运动规则内化,后面所有项目都会顺畅很多。
2.1 默认坐标系和初始方向:小海龟的“出生配置”
打开turtle默认的画布,中心点就是坐标系的原点(0, 0),向右是x轴正方向,向上是y轴正方向。这个跟你在数学课上学到的直角坐标系完全一致。但有一个细节很容易被忽略:小海龟的初始朝向是向右,也就是x轴正方向。
我见过不少新手画正方形,代码长这样:
t.forward(100) t.right(90) t.forward(100) t.right(90) t.forward(100) t.right(90) t.forward(100)这段代码没问题,跑起来也确实能画出一个正方形。但你有没有想过,如果初始朝向不是向右,这段代码画出来的图形会变成什么样?方向不同,图形会整体旋转,但边长和角度关系不变。理解了这一点,你对right()和left()的理解就不再是“向右转”和“向左转”,而是“顺时针旋转”和“逆时针旋转”。这个区别极其重要,因为右转和左转是相对于海龟当前朝向而言的,跟屏幕的固定方向没有直接关系。默认朝向为右,所以第一次t.right(90)会让海龟朝屏幕下方,而不是你直觉里的屏幕右侧。多转几次后,海龟会依次朝向下、左、上,最终回到初始朝向。
2.2 核心指令速查:先掌握这10个就够了
turtle库提供了大量方法,但作为新手阶段,只需要熟练掌握下面这张表就完全够用。等你用得熟练了,再去查其他API也不迟。
| 指令 | 作用 | 一句话说明 |
|---|---|---|
| forward(d) 或 fd(d) | 沿当前方向前进d像素 | 核心移动指令,d越大走得越远 |
| backward(d) 或 bk(d) | 沿当前方向后退d像素 | 与forward方向相反,但朝向不变 |
| right(angle) 或 rt(angle) | 当前朝向上顺时针旋转angle度 | 不移动,只转头 |
| left(angle) 或 lt(angle) | 当前朝向上逆时针旋转angle度 | 不移动,只转头 |
| penup() 或 up() | 抬笔 | 抬笔后移动不会在画布上留下痕迹 |
| pendown() 或 down() | 落笔 | 默认状态,移动时画线 |
| goto(x, y) | 移动到画布上绝对坐标(x, y)位置 | 配合抬笔使用可实现“瞬移” |
| circle(r) | 以当前位置为圆心,画半径为r的圆 | 圆的起点和方向受当前朝向影响,默认从当前点逆时针画 |
| begin_fill() / end_fill() | 在两者之间的图形中填充当前填充色 | 先要设置fillcolor |
| color() / pencolor() / fillcolor() | 设置颜色 | color可以一次设置笔色和填充色;pencolor只设置线条颜色 |
| pensize(n) | 设置线条粗细 | 数值越大线条越粗 |
| speed(n) | 设置绘图速度 | speed(0)最快,speed(1)最慢,speed(6)适中 |
| hideturtle() | 隐藏小海龟图标 | 画完作品后隐藏图标,画面更干净 |
从这张表你也可以看出,画图过程中最常用的动作组合是:抬笔、goto到指定位置、落笔、设置颜色、移动或旋转、开始填充、移动形成闭合图形、结束填充。如果你把这个组合练熟了,基本上已经能画任何多边形和组合图形了。
2.3 循环与几何结合:画一个正方形能学到什么
我见过很多人急于求成,不想在简单图形上花时间,结果到了复杂图形上反复出错。画正方形这件事,表面上是四条边加四个直角,但对新手来说,它是理解循环结构最好的载体。
import turtle t = turtle.Turtle() t.speed(1) for _ in range(4): t.forward(100) t.right(90) turtle.done()这段代码用for循环把前进和右转各执行了四次,效果跟手写四遍一模一样。你如果运行一下,会看到小海龟先向右走100像素,然后顺时针转90度,再走100像素,再转90度,如此循环四次,最后恰好回到起点,指向初始方向。这里的规律是:任何闭合的多边形,外角总和永远是360度。正方形四个外角都是90度,三角形三个外角都是120度,五角星的五个拐角是144度。所以,你不用记每个图形的外部角度,只需要记住外角和360度这条不变量,用360除以图形的边数或角数,就能得到每次要旋转的角度。
把正方形练习吃透后,再试试这个变形:把range(4)改成range(30),把right(90)改成right(12),再用speed(0),你会看到一个类似扇形的图形。或者用range(360)加right(1),它就会越来越接近一个圆。这种从正方形到圆形的连续变化过程,比直接告诉你“circle(50)可以画圆”要直观得多。你会在视觉上理解为什么圆可以被看作一个正三百六十边形,为什么计算机图形学里没有真正完美的圆,只有足够多边形的近似。
2.4 填充与抬笔:让画面不再是简笔画
画正方形只能得到轮廓线,但如果想给小房子的墙填上颜色,就得用到begin_fill和end_fill这一对方法了。它们的关系有点像给图形区块上色的开关:先调用begin_fill告诉Python“我要开始填充了”,接着画一个闭合图形,最后调用end_fill通知Python“图形画完了,现在把填好的颜色粉刷上去”。在begin_fill之前的fillcolor设置会自动起作用。比如果实:
import turtle t = turtle.Turtle() t.speed(5) t.fillcolor("lightpink") t.begin_fill() for _ in range(4): t.forward(80) t.left(90) t.end_fill() # 在另一个位置画第二个方块 t.penup() t.goto(150, 0) t.pendown() t.fillcolor("lightblue") t.begin_fill() t.circle(40) t.end_fill() turtle.done()这段代码里还出现了penup和goto的配合使用。为什么要抬笔?因为如果你不抬笔,海龟移动时会拖出一条连接两个图形的直线,这个线条通常不是你想要的。抬笔过后移动相当于在纸上悬空移动笔尖,到了新位置再落笔。这个组合是后面所有项目里最常用的基础动作,没有之一。养成“先抬笔、再goto、最后落笔”的肌肉记忆,你的画布上就不会出现奇怪的连线了。
3. 第一幅完整作品拆解:用代码画出带天空和草地的彩色小房子
接下来进入正题:画一幅像样的作品。为了让视觉效果好一点,我没有选择只画一个孤零零的房子,而是把场景做完整:画面左侧是草地和一棵小树,居中是一栋粉墙红顶的房子,屋顶上方有浅蓝色天空和带光芒的太阳。整个画面大约800像素宽、600像素高,全部使用turtle的标准绘图能力完成。
你先把完整代码跑通,再看后面的逐步拆解。
3.1 整体代码:天空、草地的绘制思路
运行的画布默认大小可能不够舒服,所以先用turtle.setup(800, 600)把画布设成宽800高600。然后先画天空和草地两个色块。没有它们,房子就飘在空中,作品会失去空间感。草地区域我定位在画布下半段,用一个覆盖左下角到右下角的矩形完成,再用一棵简单的小树点缀。天空部分用浅蓝色填充画布上半部。实现方式其实很笨但有效:把海龟抬笔走到画布一角,落笔后通过循环画一个长方形,再填充颜色。
import turtle t = turtle.Turtle() t.speed(6) t.pensize(2) turtle.setup(800, 600) # 天空 t.penup() t.goto(-400, 100) t.pendown() t.color("lightblue") t.begin_fill() for _ in range(2): t.forward(800) t.left(90) t.forward(300) t.left(90) t.end_fill() # 草地 t.penup() t.goto(-400, -200) t.pendown() t.color("lightgreen") t.begin_fill() for _ in range(2): t.forward(800) t.left(90) t.forward(300) t.left(90) t.end_fill() turtle.done()这段代码跑起来后,你看到的画面是水平上下分割的两个色块,上方浅蓝,下方浅绿,分界线在y=100这个位置。为什么分界线不平移到y=0呢?因为画布中心是(0,0),把地平线稍微抬高一点,山丘或房子的地基就能落在视野的下部,画面构图更接近人的日常观察习惯。这个看似随意的数值,其实是构图经验。
用循环画矩形时,for _ in range(2)只循环两次,每次做“前进800像素、左转90度、前进300像素、左转90度”,刚好画完一个矩形的四条边并回到原点。这是一个非常高效的多边形画法,四个边的矩形只需要两行循环体。
3.2 房子主体的填充画法
地平线准备好后,开始画主角——小房子。为了让房子视觉重心不要太飘,我把它的左下角定位在(-160, -100)附近。墙体是一个边长200的正方形,填充色我选择了带一点暖色调的浅粉色,这样配合红屋顶会更建可爱。先抬笔定位到墙体的左下角,落笔填充前先把t.fillcolor("pink")设置好,然后画正方形。
# 房子墙壁 t.penup() t.goto(-160, -100) t.pendown() t.fillcolor("mistyrose") t.begin_fill() for _ in range(4): t.forward(200) t.left(90) t.end_fill()画正方形时用left(90)和right(90)效果一样,因为四个角都是90度,无论顺时针还是逆时针最终都回到起点。这里我刻意选用left(90)是因为图形是站在地平线上绘制的,海龟先向东走,然后向北、西、南,逆时针转圈,跟生活中按逆时针方向画外轮廓的习惯一致。
3.3 屋顶的等腰三角形怎么画?
屋顶是这幅画里最需要动脑筋的地方。墙壁上方的宽度是200像素,屋顶需要比墙体略宽一些,所以我让屋顶的底边从x=-180延伸到x=180,总宽360。屋顶的高取120像素,那么屋顶尖的坐标就是(0, 20)。由于屋顶的底边与墙体上边在视觉上差了一段距离,这里需要先让海龟移动到屋顶左端点位置。用goto直接跳过来画三角形会非常方便。
# 三角形屋顶 t.penup() t.goto(-190, 100) t.pendown() t.fillcolor("firebrick") t.begin_fill() t.goto(0, 240) t.goto(190, 100) t.goto(-190, 100) t.end_fill()用goto而不是forward转left,可以让三角形的位置计算固定在绝对坐标上。屋顶左端点坐标我设置为(-190, 100),屋顶尖为(0, 240),右侧为(190, 100),这样三个点连线后就是一个等腰三角形。高度差是140像素,比墙壁上方的位置高出很多,正好能覆盖住墙体顶部。这段代码里goto接连用了三次:海龟先从屋顶左端点连线到屋顶尖,再连线到右端点,最后闭合回左端点。goto一个点是“移动到这个点并画线”,所以不需要额外画闭合线。
我调色时墙体选浅色,屋顶选深红色,形成对比。画画时颜色搭配直接影响成品效果,这个经验在turtle里同样适用。对于新手来说,可以先直接用英文颜色名,等以后再用十六进制色值进行更细腻的调色。
3.4 门窗和太阳:用绝对定位把画面补满
房子主体完成后,再给房子加上门和窗户。这个步骤涉及的所有元素都用同一种套路:抬笔、goto到一个确定性坐标、落笔、填充、画形状。窗口定位在墙体左半部分,门定位在底部中间偏右。为了让画面有层次感,我用saddlebrown画了门,用cyan画窗体,在窗棂上再画两条交叉线。
# 门 t.penup() t.goto(-30, -100) t.pendown() t.fillcolor("saddlebrown") t.begin_fill() for _ in range(2): t.forward(60) t.left(90) t.forward(100) t.left(90) t.end_fill() # 窗户 t.penup() t.goto(-130, 0) t.pendown() t.fillcolor("lightcyan") t.begin_fill() for _ in range(4): t.forward(50) t.left(90) t.end_fill() # 把窗户画出十字窗格 t.penup() t.goto(-130, 25) t.pendown() t.forward(50) t.penup() t.goto(-105, 0) t.pendown() t.left(90) t.forward(50)这段代码里有个细节值得注意:画完窗户的边框后,海龟的位置会停在窗框的左上角,也就是下一次forward的方向朝向左侧。为了画横窗格,我先抬笔跑到窗户的左中点,然后方向未知的情况下直接forward(50)大概率会画歪。所以最好用goto定位起点并把方向重新摆正。我习惯于在画横线前先t.setheading(0)让海龟恢复向右,画竖线前再用t.setheading(90)让海龟朝上。加了这个动作后,就不必担心之前旋转动作残留的方向影响了。
接下来在画面右侧画一个太阳。太阳主体就是黄色圆,然后用8条直线光芒表示放射方向。
# 太阳 t.penup() t.goto(180, 240) t.pendown() t.fillcolor("gold") t.begin_fill() t.circle(40) t.end_fill() # 太阳的光芒 t.penup() t.goto(180, 280) t.pendown() for _ in range(8): t.forward(30) t.backward(30) t.right(45)为什么太阳中心定位在(180, 240)的时候,circle(40)画出来的圆心不在(180, 240)?因为turtle的circle方法并不是以当前点作为圆心的,而是以当前位置作为圆的底部或者取决于朝向位置,它默认从当前点开始,沿逆时针画一个圆,当前位置是圆的起始点。但实际中你写t.circle(40),它会从当前点开始向左后方画圆,也就是说圆心在起始点的正上方。所以当你把海龟定位在(180, 240)时,圆心其实在(180, 280)位置,这跟想要的位置差了一个半径的距离。我还是希望圆心中心在目标位置,所以先抬笔把海龟移到(180, 280)再circle(40)。这个经验对后续画同心圆和复杂场景很有用。
我一直强调,画精确图形之前先在纸上或脑子里把坐标图转一转,把“海龟当前位置会落在图形的哪个位置”搞清楚,再动手写circle命令,这样可以省去来回调试的时间。
3.5 添加细节:小树、烟囱和点缀后,作品感就出来了
一栋房子如果只有墙、屋顶、门、窗、太阳,还是显得有点单薄。为了让画面更像一幅艺术创作,我又加了小树和烟囱。树可以画成棕色树干加三个绿色同心圆树冠,烟囱则在屋顶上画一个矩形小方块,并在烟囱口画几圈袅袅上升的线。
# 树 t.penup() t.goto(-280, -120) t.pendown() t.fillcolor("sienna") t.begin_fill() for _ in range(2): t.forward(30) t.left(90) t.forward(80) t.left(90) t.end_fill() # 树冠 t.penup() t.goto(-265, -40) t.pendown() t.fillcolor("forestgreen") t.begin_fill() t.circle(35) t.end_fill() t.penup() t.goto(-295, -20) t.pendown() t.begin_fill() t.circle(25) t.end_fill() t.penup() t.goto(-235, -20) t.pendown() t.begin_fill() t.circle(25) t.end_fill()这段代码产生的效果是:棕色矩形向上立起,顶端三个绿色圆形像三团云朵叠在一起。树的底部坐标关系到草地的位置,所以放在y=-120附近,这样树干不会悬在半空。
烟囱的位置要注意和屋顶配合。红色三角形房顶的轮廓是固定的,烟囱坐标应放在屋顶斜面内,不能超过屋顶尖,同时视觉上必须比屋顶高一点才能让人看出是烟囱。在代码里,烟囱底部定位在(80, 130),宽度45,高度70。
# 烟囱 t.penup() t.goto(80, 130) t.pendown() t.fillcolor("gray") t.begin_fill() for _ in range(2): t.forward(45) t.left(90) t.forward(70) t.left(90) t.end_fill() # 烟雾(几段小圆) t.penup() t.goto(120, 240) t.pendown() t.pencolor("lightgray") t.circle(10, 180) t.penup() t.goto(138, 270) t.pendown() t.circle(15, 180) t.penup() t.goto(162, 305) t.pendown() t.circle(20, 180)circle的第二个参数180表示只画半圆,非常适合画这种袅袅上升的烟雾线条。如果不加第二个参数,它会画一整圈,那就不是烟雾而是圆圈了。微调定位之后,烟雾曲线从烟囱口向右上方飘散,画面一下子就有了生活气息。
3.6 从这段项目代码里沉淀下来的三点经验
第一,整个作品的核心把控点其实不在画画本身,而在坐标计算。我强烈建议你在写代码前先在白纸上画一个800乘600的网格图,标出房子的左右边界、草地的分界、树的坐标。没有这张草图,你就会陷入不断用试错法调坐标的循环里。我一开始直接凭感觉定位,结果发现屋顶和墙体错位了20像素,门窗的位置也歪了,后来多花了很多时间。画草图虽然看起来多此一举,实际能省下好几倍的调试时间。
第二,颜色填充之后要马上涂掉不需要的边界,就用begin_fill和end_fill包住要上色的图形,不需要考虑重叠图形的边界线。由于end_fill会自动从最后一点连到第一个点并填充,所以矩形、三角形等闭合图形非常容易设置填充色。
第三,在绘制顺序上,先画背景再画主体,先画大面积再画细节。如果你先画窗户再画墙,墙面会盖住窗,顺序搞错了要反复重画。这个和画油画时的图层逻辑一模一样。
4. 进阶玩法:用递归画一棵随机分形树,体验“生成艺术”
小房子画完以后,你的turtle基础已经足够扎实了。但如果每次画树都要我手动画树干、一个个圆树冠,那么工作量极大且每棵树看起来都不太自然。这时候就轮到计算机最擅长的领域登场了——用递归生成一棵有随机生长感的分形树。这个项目不需要你学任何新库,只需要掌握一个可以让代码看起来非常“聪明”的编程技巧:递归函数。
4.1 递归是什么,它跟画树有什么关系
递归这个概念听起来吓人,其实说白了就是函数自己调用自己。它跟循环很相似,都是重复执行某段逻辑。区别在于递归往往会把一个问题拆成更小的同类问题,而循环更偏于固定次数的重复。
拿画树来说,树的生长规律可以用一句话概括:树干在末端分叉出两个较短的枝干,每个枝干继续生长,又在末端分叉出两个更短的枝干,如此反复,直到枝干短到某个体积以下就不再继续。这个定义本身就是递归的:画树函数在执行时,先画一段粗树枝,然后调用“画树函数”来画较细的左枝,再调用一次来画较细的右枝,直到长度小于设定的阈值。
为了制造自然感,每次递归我都给分叉角度和长度加入随机数。这样即使规则一样,每次运行产生的树也会因随机数而变化,形成大自然里独一无二的艺术作品。
4.2 分形树的完整代码演示
下面这段代码可以直接运行。画出来的树会从画布底部向上生长,枝干从棕色逐渐过渡到绿色。每一轮递归都会产生两个略微左右摇摆的新枝,看起来特别接近真实植物的生长逻辑。
import turtle import random t = turtle.Turtle() t.speed(0) t.pensize(20) t.pencolor("saddlebrown") t.penup() t.goto(0, -300) t.setheading(90) t.pendown() def draw_tree(branch_len, t): if branch_len < 15: t.pencolor("green") t.pensize(3) t.forward(branch_len) t.backward(branch_len) return t.pensize(branch_len * 0.15) t.forward(branch_len) angle1 = random.randint(20, 35) angle2 = random.randint(20, 35) shrink = random.uniform(0.7, 0.75) t.right(angle1) draw_tree(branch_len * shrink, t) t.left(angle1 + angle2) draw_tree(branch_len * shrink, t) t.right(angle2) t.backward(branch_len) turtle.tracer(0) draw_tree(120, t) turtle.update() t.hideturtle() turtle.done()把这段代码跑起来,你会看到整棵树从底部到树梢,线条越来越细、颜色从棕色渐变成绿色。turtle.tracer(0)和turtle.update()的组合会让整棵树瞬间长出来而不是一帧一帧被看完,避免等待时间过长。
4.3 为什么分形树必须画完分支后退回原地
这段代码里的关键动作是最后一步t.backward(branch_len)。很多人第一次自己写递归画树逻辑时,会忘记把画笔退回到分叉点。你可以做一个实验:删除最后一行backward,再运行一次,你会看到树枝像被吹乱了一样,各层级交织在一起,根本无法形成树冠。
原因是这样的:递归调用draw_tree内部会消耗掉一定的前进和后退。假设某个树枝长度为100,海龟先前进100到达这个树枝的末端,之后递归画它的左右分支。左右分支的递归函数内部,无论怎么forward、right、left,函数结束的时候,海龟已经通过各自的backward退回到了这个树枝的末端。也就是说,当两个递归调用都完成时,海龟还停留在当前树枝的末端,而不是树干原来的位置。如果函数直接返回,调用它的上一级函数根本不知道应该从哪里继续。那个backward的作用就是让海龟沿着来时的路退回到起点,还原整个画布上的位置与状态。这个做法在递归绘图里被称为“状态恢复”。不仅画树如此,以后你画迷宫或分形图形时,都会需要这种做法。
理解了这一步,你就理解了一个通用递归模板的内在逻辑:先往前走,然后递归子问题,最后回到原来的位置。这个套路可以画出很多漂亮的递归图案,像是分形蕨类、雪花曲线、科赫雪花,都是由这个模板变形而来的。
4.4 调参与选择随机范围:每棵树都有一点点个性
为了让树不至于太对称,我设置了两个随机角度angle1和angle2,分别代表右转和左转的幅度,范围在20到35度之间。每层分支缩小的比例也被定义成shrink,范围在0.7到0.75之间。这样树的下层枝干会比上层粗不少,视觉上更自然。
如果你想让树显得修长,可以加大左右角度的差值,比如一边20一边40,这样偏斜会很明显;如果想画一棵“矮胖”的树,可以把shrink调高到0.85,让枝干衰减得慢一些,树冠会显得浓密。颜色的切换点由branch_len < 15这个阈值决定。把阈值提高到30,那么树叶会变多,看起来更茂盛;降低到5,树叶就只能在很小的枝干末梢出现,树看起来会比较稀疏。
我自己调了很多次参数之后的感觉是:随机数范围越小,画的树越像同一种模板长出的不同个体;随机数范围越大,树的形态差别就越大,甚至可能出现明显的一边倒。想要稳定的“森林感”,shrink的稳定性比角度更重要。shrink相当于控制整棵树生长的资源总量。
4.5 随机分形树与前面小房子的区别:艺术感和技术感结合的甜点
在小房子项目中,你是那个在纸上规划好一切的设计师,代码只是在执行一套确定性的指令。而在分形树项目中,你更接近于定义一套“规则”,然后让代码在规则里自己探索出形态。这也是我特别推荐在学编程的初期就接触分形树的原因——它不会像练习题那样刻板,每一次运行都给你一个不同的结果,比单纯画静态图好玩太多了。
你可以在树旁边加一个太阳,也可以同时生成五六棵树摆成一排,背景用淡蓝色,地面用绿色,立刻就是一幅具有装饰感的数字艺术作品。而且你会发现,学习函数和递归之前那些很抽象的概念,在这里实实在在地变成了“你掌握的一种画笔”。
5. 运行过程会被这几个问题拦住:窗口闪退、笔没抬起来、方向弄反了
不管画小房子还是分形树,新手运行turtle脚本时总会被几个通用问题狠狠折磨。我干脆把它们集中写在一个章节里,免得你每跑一个脚本都来问一次。
5.1 窗口一闪而过:不是代码写错了,是少了“看门狗”
最最常见的现象是:运行代码后,画布出现不到一秒就自动关闭了,你根本来不及看清画出来的东西。这个问题在之前提过,根源在于程序执行完毕时窗口随之关闭。解决办法就是在脚本最后一行的绘图指令后面加一行turtle.done()或turtle.exitonclick()。turtle.done()让窗口保持打开直到你手动关闭;turtle.exitonclick()则需要你点击画面才会退出,很适合逐帧观察画图过程。我给初学者的建议是直接写exitonclick(),一旦效果满意再改成done()也行。
需要注意的是,done()之后不能再接任何绘图指令。如果你想在点击后再让海龟画点什么,需要用onclick绑定事件。对于目前的项目,把这个函数理解为脚本的“句号”就好。
5.2 图画出来了但位置飘在半空或跑出界:坐标系没搞明白
另一种常见情况是:图形的主体画对了,但位置不对,跑到了画布边缘或者偏离了预期区域。原因基本是坐标定位的混乱。turtle的坐标原点默认在窗口中心,和很多图像处理库的左上角原点不一样。比如你在网上看到某个人分享的代码里用的坐标是(100, 100),那通常是基于他们自己设置的画布大小得到的。如果你的setup跟他的不同,图形位置自然也会不同。
在动手之前,先看清你的画布中心到底在哪里。你可以用t.setpos(0, 0)或t.home()让海龟回到中心点,再画一些简单的参考线确认边界。还有一种笨办法就是运行代码后打开画布,用turtle做过墙的坐标打印,把当前海龟位置的x和y用t.pos()查看。这部分代码调试速度