
社区活动室那台用了五年的笔记本是我第一次带着一群十来岁的孩子做游戏的起点。第一节课在 Scratch 里拖积木最后一节课在 Python 里敲代码中间隔着一道很多人迈不过去的坎我用一个项目把它填平了——3D 跑酷。听起来唬人实际上它是我试过的、从积木思维过渡到文本编程最顺的一条路Scratch 版两节课就能跑起来Python 版第一天就能看到画面动起来而两个版本背后跑的其实是同一套逻辑。这篇文章我会把两版都拆开讲包括坐标系怎么换算、克隆体为什么不听话、Python 环境怎么装、透视投影那一行公式怎么推、难度曲线怎么调才不劝退。适合带孩子的家长、信息课老师、以及刚学完 Python 基础语法却不知道拿什么练手的人。不需要你会三角函数只要会四则运算就够。1. 为什么我把 3D 跑酷当成 Scratch 到 Python 的第一座桥选练手项目这件事比大多数人想象的更重要。选错了学到的东西迁移不过去换个平台就得从头再来选对了你会发现 Python 里写的每一行代码都能在 Scratch 里找到对应的那摞积木。3D 跑酷在我这里属于后者原因有三层下面一层层说。1.1 跑酷游戏的骨架只有三件事把任何一款跑酷游戏拆到最底层剩下的东西少得可怜第一是状态推进也就是世界朝着玩家靠近障碍的深度值不断减小第二是判定玩家的位置和障碍的位置在同一时刻是否重叠第三是反馈撞上了要闪红、跳起来要有抛物线、速度变快要有视觉上的拉扯感。就这三件事没有第四件。这意味着什么意味着你不需要写完一整个游戏才能理解它你可以先把世界往我靠近这一件事做出来屏幕上出现几条线往后跑那一刻成就感就来了。我带学生的时候第一节课的目标就是让地面上的刻度线动起来只要做到这一点后面的东西都是往骨架上挂肉。更重要的是这三件事跟编程语言没有半毛钱关系。Scratch 里的重复执行和 Python 里的while True是同一个东西如果…那么和if是同一个东西。语言只是外壳骨架才是要学的东西。1.2 Scratch 里的3D是算出来的不是画出来的这一点必须一开始就讲明白否则后面全是糊涂账。Scratch 是纯 2D 舞台它的坐标系只有 x 和 y两个轴没有 z。那所谓的3D从哪来答案是你自己在脑子里维护一个 z然后每一帧把这个 z 换算成屏幕上的 x、y 和一个缩放比例。换算的规则非常朴素就一句话东西离得越远看起来越小而且越靠近画面的中心。用一个公式表示就是屏幕缩放 焦距 / 深度。深度 Z 越大缩放越小Z 越小缩放越大。再乘上物体原本的世界坐标就得到了它在屏幕上的位置。这个公式是所有 3D 图形的地基从手机游戏到电影特效第一层都是它。我在课上会用一个特别土的演示让学生把课本举到眼前然后慢慢推远。课本的实际宽度没变但它在视野里占的比例变小了。Scratch 做的就是这件事的数学版本。理解了这一点后面看到一堆除法和乘法就不会慌了。1.3 几个常见练手项目的横向对比很多人问为什么不用贪吃蛇、愤怒的小鸟、九九乘法表这些经典项目来过渡它们当然是好项目但训练的肌肉不一样。我把常见的几个摆在一起对比一下你就能看出差别在哪。练手项目主要训练的能力迁移到 Python 的难度是否涉及深度轴九九乘法表循环嵌套、字符串拼接低无冒泡排序列表下标、元素交换低无贪吃蛇网格坐标、队列、碰撞判定中无愤怒的小鸟抛物线物理、克隆体管理中高伪深度3D 跑酷连续坐标、透视投影、对象池、难度曲线高有差别在哪九九乘法表和冒泡排序练的是语法它们的结果是打印出来的一堆文本没有画面学到的东西很难迁移到游戏或者工具开发上。贪吃蛇练的是离散网格坐标都是整数格子一旦换成连续坐标就要重新想。而 3D 跑酷练的是连续坐标 时间推进这套东西在 Python 里做数据分析、做动画、做模拟仿真用的都是同一套思维。所以我的建议是语法练习照做但真正想跨过那道坎用一个带画面的项目。带画面的项目里3D 跑酷的性价比最高。1.4 同构不同壳这才是能迁移的原因同构这个词有点抽象我举个具体例子。Scratch 里你会写这么一段重复执行把 z 减少速度如果 z 小于 2 那么判断是否撞上。Python 里对应的写法是主循环里o.z - speed * dt然后if o.z 2: check_hit(o)。两边的结构一模一样连变量名都可以不改。区别只在于Scratch 的重复执行自带帧率一秒钟跑 30 次Python 的while循环跑得飞快一秒钟可能跑几千次所以你必须自己引入时间也就是后面要讲的 dt。这是我见过的、从 Scratch 到 Python 最容易踩的第一个坑也是最有价值的一个坑因为它逼你去理解帧率这个概念。把这四层想清楚再动手就不容易半途而废。下面先讲 Scratch 版。2. Scratch 版 3D 跑酷不装任何引擎也能做出纵深感Scratch 版的目标很明确屏幕上要有一块地面往你冲过来三条轨道上时不时冒出障碍你能左右换道、能跳起来躲撞上就结束。整个项目用三个角色就够了地面、障碍模板、玩家。听起来简单但有几个地方不注意会卡很久。2.1 舞台坐标、地平线和焦距怎么定先把三个基础数值定下来这三个数决定整个画面的镜头感定错了后面全歪。地平线舞台中心是 (0,0)y 从 -180 到 180。我把地平线放在 y -30也就是说地面在 y 小于 -30 的区域天空在上面。焦距 F我取 480。这个数越大画面越长焦远处的东西看起来越大越小越广角透视越夸张。480 对应的大概是 40 度左右的视场看着舒服。相机高度 H我取 1.4 个世界单位。它决定了地面在地平线下方偏移多少。换算公式就两组缩放比例 s F / Z 屏幕 x 轨道X × s 屏幕 y 地平线 - H × s注意 Scratch 的 y 轴是向上为正的所以从地平线往下走要用减号。这一点跟 Python 的 pygame 正好相反pygame 里 y 轴向下为正公式得写成加号。我当年在这上面栽过一次画面里的障碍全跑到天上去了排查了半小时才发现是符号搞反。代入几个具体数值感受一下。Z 60 的时候s 8三条轨道的间距只有 8 个像素障碍几乎挤成一团高度偏移 11 像素Z 10 的时候s 48轨道间距 48 像素高度偏移 67 像素Z 5 的时候s 96轨道间距 96 像素高度偏移 134 像素物体已经跑到画面底部附近了。这组数字说明什么说明远处的东西天然就该挤在一起这不是 bug是透视本身。我建议把 Z 的有效范围定在 60 到 4 之间。Z 大于 60 就创建Z 小于 4 就删除。低于 4 的物体在屏幕上已经出界了留着只会浪费克隆体配额。2.2 三条轨道的摆放和障碍生成节奏轨道位置简单X 取 -1、0、1 三个值间距 1 个世界单位。用 Scratch 的变量表示就是轨道 1 / 2 / 3实际计算时用轨道 - 2得到 -1、0、1。障碍生成我用的是计时器 随机间隔的方案而不是每隔固定时间生成一个。理由很实在固定间隔玩两分钟就腻了因为节奏完全可预测随机间隔会让玩家一直保持一点紧张感。具体做法是在障碍模板角色里挂一个本地变量生成倒计时每次生成完就重置成一个随机数。间隔的取值范围要跟当前速度挂钩。我的公式是间隔秒数 随机(0.75, 1.25) × (基础速度 / 当前速度) × 2基础速度取 9最大速度取 26。速度越快间隔越短但缩得比速度慢一点这样整体难度是缓慢上升的。这个 2 倍系数是我试出来的系数取 1 的时候后期太密玩家根本来不及反应取 3 又太松。你可以按自己的手感微调。还有个小规则整面墙类型的障碍不能三条道同时出。我一开始没想到这一点结果生成了一个三面墙的关卡玩家必死气得学生当场把电脑合上了。现在的做法是每次生成时至少要留一条空轨道给玩家。2.3 克隆体的正确用法让每个障碍自己跑这是整个 Scratch 版最核心、也最容易翻车的地方。热搜上常年挂着Scratch 做贪吃蛇如何让克隆体随本体运行说明这个问题卡住了很多人。我把它彻底讲清楚。首先纠正一个常见误解克隆体不是本体的影子克隆体是本体在创建那一刻的一份独立拷贝。它复制了位置、方向、造型、大小以及所有仅适用于当前角色的变量值。创建完之后本体再怎么动克隆体也不会跟着动。所以让克隆体随本体运行这个说法本身就有问题正确的思路是反过来让本体彻底停下来所有运动都交给克隆体自己算。我的做法是这样的。障碍模板角色本体永远隐藏并且停在 (0,0)它的脚本只有一段当绿旗被点击 隐藏 移到 x:0 y:0 删除本角色所有克隆体 重复执行 等待 (0.75 ~ 1.25 秒之间的随机数) 秒 如果 游戏进行中 1 那么 将 [暂存轨道] 设为 在 1 和 3 之间取随机数 将 [暂存类型] 设为 在 1 和 3 之间取随机数 创建 [本角色] 的克隆体注意这里的暂存轨道和暂存类型必须是全局变量因为本体没有办法给自己即将创建的克隆体传参只能先写进全局变量克隆体一启动就立刻读走。克隆体自己的脚本是这样的当作为克隆体启动时 将 [我的Z] 设为 60 // 局部变量 将 [我的轨道] 设为 暂存轨道 // 局部变量 将 [我的类型] 设为 暂存类型 // 局部变量 显示 重复执行直到 (我的Z) 4 将 [我的Z] 增加 (0 - 当前速度) × (1 / 30) 将 [比例] 设为 480 / (我的Z) 移到 x: ((我的轨道) - 2) × (比例) y: (-30 - 1.4 × (比例)) 将大小设为 (100 × (比例) × 0.35) % ...这里的关键点在于我的Z、我的轨道、我的类型这三个变量必须是**仅适用于当前角色**的局部变量。Scratch 3.0 在创建克隆体时会把局部变量的当前值复制一份给克隆体所以每个克隆体都有自己独立的一份互不干扰。如果你图省事用了全局变量三个障碍会在同一时刻共用一个 Z 值画面会呈现出一种诡异的三个障碍叠在一起走的效果。0.35这个系数是造型尺寸的换算系数。因为 Scratch 的大小是百分比而造型本身有一个原始像素尺寸。我的障碍造型画的是 60 像素宽想让它对应世界里的 0.8 个单位宽就得到0.8 × (F/Z) / 60整理一下就是(比例) × 0.0133乘以 100 变成百分比就是 1.33。我在上面写的 0.35 是把造型尺寸按不同基准算的你要是自己画造型记得重新算一遍别照抄我的数。2.4 用亮度和虚像做出远近层次物体只是变小还不够远处的东西还得糊一点、暗一点眼睛才会真的把它当作远处。Scratch 里有两个外观积木正好干这事亮度和虚像。我的做法是按 Z 分三段给不同的亮度值深度 Z亮度设置虚像设置视觉意图60 ~ 35-3555远景雾化几乎只剩轮廓35 ~ 15-1525中景能看清但偏暗15 ~ 400近景完全清晰亮度设成负数就是变暗这一点挺多人不知道。另外亮度值是叠加的不是设定的所以每次循环里要么用将亮度设为需要一个基准变量要么用将亮度增加配合反向值抵消否则颜色会一路飘下去最后障碍会变成纯黑。我一般用一个局部变量基础亮度存着每帧将亮度设为 基础亮度 距离修正。顺便说一句亮度还能用来做别的效果。我做受伤反馈的时候就是让玩家角色瞬间把亮度拉到 100全白再恢复一帧的闪白比任何动画都直接。有个学生把这个技巧用在了他的 Scratch 作品集里做了个雷电劈中角色的效果其实是同一招。2.5 顺序、分支、循环在跑酷里的落点Scratch 教学里顺序、分支、循环是老三样但很多孩子学完不知道用在哪。跑酷项目刚好把三种结构都用满了而且是不得不用的那种。顺序体现在一帧之内的处理流程先更新时间再更新障碍深度再做碰撞判定最后重绘。这个顺序不能乱。我有一次把碰撞判定挪到更新深度之前结果判定用的是上一帧的位置玩家在高速下会莫名其妙地穿过障碍看起来像穿模。分支体现在碰撞判定上如果 (我的Z) 2.4 那么 如果 (my 轨道) (玩家轨道) 那么 如果 (玩家离地高度) (障碍高度) 那么 将 [游戏进行中] 设为 0三层嵌套每一层都是一个不同的条件维度距离、轨道、高度。这比如果分数大于 10 那么这种教学例子有意思多了孩子能感觉到条件是真的在筛选东西。循环就是那个重复执行直到。这里我特意用了直到而不是重复执行因为克隆体需要在 Z 小于 4 的时候自己结束。如果用了无限循环克隆体永远不会消失跑一局下来舞台上有几百个克隆体帧率直接掉到个位数。Scratch 3.0 的克隆体上限是 300 个超了就创建失败而且不会有任何提示你会觉得咦我怎么放不出障碍了其实是配额满了。2.6 几个必须提前知道的坑第一不要用碰到角色做碰撞判定。在伪 3D 里远处的小障碍和玩家的碰撞箱在屏幕上可能根本不重叠但逻辑上已经撞上了。用 Z 值区间判定别用像素碰撞。第二克隆体创建的位置会继承本体的位置。所以本体一定要停在固定位置别让本体到处乱跑。我见过有人把本体当成最前面的那个障碍来用结果新生成的克隆体全都出现在那个位置上看起来像是障碍凭空冒出来。第三变量作用域选错了不会报错只会行为诡异。这是 Scratch 最坑的一点。判断标准很简单这个变量是不是每个障碍各有一份是就用局部不是就用全局。玩家轨道、游戏状态、当前速度是全局Z、轨道、类型是局部。第四速度单位要统一。我用的速度是世界单位每秒所以每帧的减量是速度 / 30因为 Scratch 默认 30 帧。如果你用每帧多少单位来定义速度那就不用除。两种写法都对但别混着用混着用速度会差 30 倍。3. Python 版 3D 跑酷环境搭好一行公式跑起来从 Scratch 切到 Python第一道坎不是代码是环境。我见过太多人卡在pip 不是内部或外部命令这种提示上然后就不了了之了。所以这一节先把环境讲透再讲代码。3.1 环境准备从下载安装到编辑器配置下载与安装。去 python.org 的下载页拿安装包注意两个点一是选版本我建议 3.10 到 3.12 之间太新的版本有些库还没跟上二是 Windows 上安装时务必勾选 Add python.exe to PATH这个勾选决定了你能不能在命令行里直接敲python。如果忘了勾别急着重装卸载后重新装一遍最省事比手动改环境变量靠谱。macOS 上系统自带一个 Python 3但不要用它。装官方的安装包装完之后命令行里对应的是python3和pip3。Linux 上用包管理器装完之后可能要额外装python3-venv这个包经常被漏掉然后python -m venv会报错。装完验证一下python --version pip --version两条都出结果才算成功。如果python没反应但py有反应说明你装的是 Windows 商店版本或者路径没配好。虚拟环境。这一步很多人跳过然后过半年发现自己电脑上有八个不同版本的同名库互相打架。养成习惯python -m venv .venv # Windows .venv\Scripts\activate # macOS / Linux source .venv/bin/activate激活之后命令行前面会出现(.venv)的字样。以后所有pip install都在这层壳里做卸载项目的时候直接把文件夹删掉就行干净。编辑器。我主要用 VSCode配置流程是装 Python 扩展然后按CtrlShiftP输入 Python: Select Interpreter选刚才那个.venv里的解释器。这一步不选编辑器用的是全局环境你装的库它看不见代码下面会全是黄色波浪线。PyCharm 的流程类似在 Settings 里的 Project Interpreter 选虚拟环境。装库。这个项目我用 pygamepip install pygame国内网络如果慢加个镜像参数就行不加也能装就是等一会儿。3.2 选型pygame 伪 3D 和 Ursina 真 3D 怎么选Python 做 3D 有两条路我把它们的差别列出来你按需求挑。方案底层学习曲线适合做什么我的建议pygameSDL2平缓会 Python 基础就能上2D 游戏、伪 3D 效果推荐先走这条UrsinaPanda3D中等需要理解实体和相机真 3D 场景、第一人称熟练后再上moderngl / PyOpenGLOpenGL陡峭要懂着色器自定义渲染不建议入门碰我选 pygame 打头阵理由很直接它跟 Scratch 版的思路是一一对应的。Scratch 里你手算缩放pygame 里也是手算缩放中间没有黑盒。而 Ursina 给你一个Entity(modelcube)它会自己处理投影和相机学起来爽但你其实没搞懂背后发生了什么换个引擎又得重来。还有个实际考虑pygame 的依赖非常好装一个 pip 命令搞定。Panda3D 的依赖体积大在配置一般的机器上编译时间很长。带课的时候装环境的时间越短越好。等你把 pygame 版写完再去看 Ursina会发现它做的事情你全都懂只是它帮你把代码写好了。这个顺序才是对的。3.3 透视投影一行公式把 3D 压到 2Dpygame 的坐标系跟 Scratch 不一样这点必须先说清楚pygame 的原点在左上角x 向右为正y 向下为正。窗口大小我设成 960 × 540那么屏幕中心是 (480, 270)。投影函数就三行def project(x, y, z): s FOCAL / z sx WIDTH * 0.5 x * s sy HORIZON (CAM_Y - y) * s return sx, sy, s逐项解释。s FOCAL / z就是缩放比例跟 Scratch 里完全一样。x * s是世界横向坐标换算成屏幕上的横向像素。(CAM_Y - y) * s是纵向相机在高度 CAM_Y 的位置物体在高度 y 的位置两者差多少就决定了它在屏幕上离地平线多远。用具体的数字验证一下。设FOCAL 420、CAM_Y 1.6、HORIZON 216也就是 540 的 40%。一个地面上的物体y 0z 20s 21sy 216 1.6 × 21 249.6。地平线下方 34 像素。同一个物体z 6s 70sy 216 112 328。地平线下方 112 像素明显更靠下。一个悬空的物体y 1.6z 20sy 216 0 216正好落在地平线上。这说明相机高度就是视线所在的高度跟你站在地上眼睛的高度是一个意思。这就是全部。没有矩阵没有四元数一除一乘一加。3D 游戏里最核心的那部分数学其实简单到有点让人失望。3.4 一份能直接跑起来的 pygame 骨架下面这份代码可以直接存成main.py运行方向键或 A/D 换道空格跳撞到东西会显示提示ESC 退出。import random import sys import pygame WIDTH, HEIGHT 960, 540 HORIZON int(HEIGHT * 0.40) FOCAL, CAM_Y 420.0, 1.6 LANE_X (-1.5, 0.0, 1.5) GRAVITY, JUMP_V, PLAYER_H 26.0, 8.8, 1.0 BASE_SPEED, MAX_SPEED, ACCEL 9.0, 26.0, 0.55 class Obstacle: def __init__(self, lane, z, kind): self.lane, self.z, self.kind lane, z, kind self.x LANE_X[lane] if kind hurdle: # 低栏跳过去 self.y0, self.h 0.0, 0.9 elif kind bar: # 高杆别跳 self.y0, self.h 1.5, 0.8 else: # 整面墙换道 self.y0, self.h 0.0, 2.6 self.w 1.1 def project(x, y, z): s FOCAL / z return WIDTH * 0.5 x * s, HORIZON (CAM_Y - y) * s, s def obstacle_rect(o): left, bottom, _ project(o.x - o.w / 2, o.y0, o.z) right, top, _ project(o.x o.w / 2, o.y0 o.h, o.z) return pygame.Rect(left, top, max(1, right - left), max(1, bottom - top)) def draw_ground(screen, offset): pygame.draw.rect(screen, (28, 32, 44), (0, HORIZON, WIDTH, HEIGHT - HORIZON)) for lane_x in (-0.75, 0.75): n project(lane_x, 0, 1.0) f project(lane_x, 0, 60.0) pygame.draw.line(screen, (60, 70, 95), n[:2], f[:2], 2) z 60.0 - (offset % 4.0) while z 1.0: a project(-3.0, 0, z) b project(3.0, 0, z) pygame.draw.line(screen, (46, 54, 74), a[:2], b[:2], 1) z - 4.0 def main(): pygame.init() screen pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption(3D Runner) clock pygame.time.Clock() font pygame.font.SysFont(microsoftyahei,simhei,arial, 22) lane, py, vy, on_ground 1, 0.0, 0.0, True obstacles, timer, dist, speed, dead [], 0.0, 0.0, BASE_SPEED, False while True: dt min(clock.tick(60) / 1000.0, 1 / 30) for e in pygame.event.get(): if e.type pygame.QUIT: pygame.quit(); sys.exit() if e.type pygame.KEYDOWN: if e.key pygame.K_ESCAPE: pygame.quit(); sys.exit() if not dead: if e.key in (pygame.K_LEFT, pygame.K_a): lane max(0, lane - 1) elif e.key in (pygame.K_RIGHT, pygame.K_d): lane min(2, lane 1) elif e.key in (pygame.K_SPACE, pygame.K_w, pygame.K_UP) and on_ground: vy, on_ground JUMP_V, False if not dead: speed min(MAX_SPEED, speed ACCEL * dt) dist speed * dt vy - GRAVITY * dt py vy * dt if py 0: py, vy, on_ground 0.0, 0.0, True timer - dt if timer 0: obstacles.append(Obstacle(random.randrange(3), 55.0, random.choice((hurdle, bar, wall)))) timer random.uniform(0.75, 1.25) * (BASE_SPEED / speed) * 2.0 for o in obstacles: o.z - speed * dt obstacles [o for o in obstacles if o.z 0.6] for o in obstacles: if 0.4 o.z 1.6 and o.lane lane: if o.kind hurdle and py o.y0 o.h: dead True elif o.kind bar and py PLAYER_H o.y0: dead True elif o.kind wall: dead True screen.fill((16, 18, 26)) draw_ground(screen, dist) for o in sorted(obstacles, keylambda k: -k.z): color (90, 200, 160) if o.kind hurdle else \ (230, 160, 80) if o.kind bar else (200, 90, 110) pygame.draw.rect(screen, color, obstacle_rect(o), border_radius4) s FOCAL / 3.5 pxp WIDTH * 0.5 LANE_X[lane] * s foot HORIZON (CAM_Y - py) * s pw, ph int(s), int(PLAYER_H * s) pygame.draw.rect(screen, (120, 190, 255), (pxp - pw / 2, foot - ph, pw, ph), border_radius8) screen.blit(font.render(f速度 {speed:4.1f} 距离 {dist:6.1f}, True, (220, 230, 245)), (16, 12)) if dead: screen.blit(font.render(撞上了按 ESC 退出, True, (255, 120, 120)), (WIDTH // 2 - 110, HEIGHT // 2)) pygame.display.flip() if __name__ __main__: main()几点说明。s FOCAL / 3.5是玩家角色固定显示在深度 3.5 的位置相当于摄像机前方的自己。abs()在这里没直接用到但你在写障碍是否已经越过玩家的更精确判定时会需要它比如abs(o.z - 1.0) 0.05这种。int(s)那两行是类型转换pygame 的draw.rect不接受浮点数坐标不转换会报TypeError这个错误我遇到过好几次。碰撞判定我用了0.4 o.z 1.6这个区间是个偷懒写法。更严谨的做法是记录上一帧的 z判断这一帧有没有跨过 1.0 这个平面这叫扫掠检测。入门阶段用区间够了代价是高速情况下可能漏判。3.5 从积木到代码的对照表下面这张表是我贴在教室里的一张对照表学生写 Python 版的时候会一直回头看它。Scratch 积木Python 对应写法需要注意的地方当绿旗被点击def main():if __name__ __main__:入口只有一个重复执行while True:必须自己控制帧率重复执行 10 次for i in range(10):次数固定就用 for等待 1 秒pygame.time.delay(1000)会卡住整个程序慎用如果…那么if ...:缩进就是语法如果…否则if ...: / else:别用两个 if 代替将变量增加 1var 1Python 里没有增加积木移到 x: y:pygame.draw.rect(screen, color, rect)位置和绘制是一体的创建克隆体obstacles.append(Obstacle(...))列表就是克隆体容器删除此克隆体obstacles.remove(o)或列表推导边遍历边删要小心将大小设为 %在 rect 里按比例算宽高没有独立的大小属性计时器clock.tick(60) / 1000.0得到的是秒随机数random.uniform(a, b)浮点用 uniform整数用 randint这张表最值钱的一行是创建克隆体和删除此克隆体对应的两行。Scratch 里克隆体是引擎帮你管理的Python 里你得自己用一个列表来装、自己判断什么时候移除。这就是对象池的雏形。顺便说一句列表推导式[o for o in obstacles if o.z 0.6]这一行本质上就是删除所有满足条件的克隆体比写循环倒数着删干净多了。4. 两版对照把同一个游戏拆成同样的模块写完两版之后我把它们并排放着看了一遍发现有意思的地方代码量差了好几倍结构却几乎能一一对上。这一节把几个关键模块拉出来对照着说。4.1 主循环与时间步长Scratch 的主循环是隐式的——你写一个重复执行它就每秒钟跑 30 次一秒误差通常不超过一点点。你什么都不用管。Python 里没有这层保护。while True会以 CPU 能跑多快就跑多快的速度转圈一台机器上一秒几千次换台机器变成几万次。如果你直接写o.z - speed那这个游戏在快机器上会瞬间结束。所以必须引入时间变量 dt。dt min(clock.tick(60) / 1000.0, 1 / 30)这行代码干了两件事。clock.tick(60)让循环最多跑 60 帧每秒同时返回距上一帧过了多少毫秒除以 1000 得到秒。min(..., 1/30)是夹紧防止某一帧卡了半秒钟比如窗口被拖动、系统在更新导致 dt 突然变成 0.5所有的物体一瞬间向前跳了半个屏幕。这个 bug 在开发时很难复现玩家却经常遇到尤其是老机器上。夹紧到 1/30 秒最坏情况就是慢一点不会穿模。Scratch 版的对应处理是把速度定义成每秒多少单位然后每帧减去速度 / 30。两边的思路是一样的把速度和时间解耦。4.2 障碍生成与对象池Scratch 用克隆体Python 用列表但管理逻辑完全一样生成、更新、判定、销毁。差别在于性能上的考量。Scratch 的克隆体上限是 300而且创建和销毁都有开销所以我在 Scratch 版里把生成间隔调得比较宽松屏幕上同时存在十几个障碍就到头了。Python 这边列表可以放几千个对象但你也没必要——超过二十个障碍同时在场玩家根本反应不过来。我在 Python 版里加了一个 Scratch 版没有的东西生成时检查轨道占用。在创建新障碍之前先扫一遍现有的障碍列表如果某条轨道在 Z ∈ [50, 60] 区间已经有一个了就换一条轨道。这样能彻底避免两个障碍贴在一起的情况。Scratch 版也能做但需要一个额外的列表变量写起来啰嗦我就没加。4.3 难度曲线怎么调才不劝退这是我在带课过程中改得最多的地方。最早我的做法是速度线性增长speed 9 0.5 * tt 是游戏时间。跑下来发现前 20 秒太简单后面 30 秒直接崩盘——因为线性增长意味着难度是加速上升的玩家的反应时间是固定的但障碍间隔在缩短。后来我换成了分段方案实际效果最好阶段时间范围速度区间障碍间隔系数主要障碍类型热身0 ~ 15 秒9 → 121.0只有低栏适应15 ~ 40 秒12 → 170.85低栏 高杆加压40 ~ 70 秒17 → 220.7三种都有冲刺70 秒以后22 → 260.6三种都有间隔随机浮动大关键点在热身阶段只出低栏。为什么因为玩家需要时间建立肌肉记忆——跳到一半发现这个不能跳、那个必须换道认知负荷会崩。让玩家在最开始的一分钟里只学一个动作接受度会高很多。我给课程的评分标准也是按这个来的能跑过热身阶段算及格跑过 70 秒算优秀。4.4 手感三要素与参数表手感这个东西没法量化但可以拆成三个可调的参数。我把两版共用的参数整理成一张表你照着调就行。参数我用的值调大的效果调小的效果起跳初速度8.8跳得更高滞空更久跳得矮可能过不去低栏重力加速度26.0下落快落地干脆飘像是在月球上滞空时间推算约 0.68 秒——判定窗口Z ∈ (0.4, 1.6)更宽容更严苛容易擦边死换道响应瞬间切换灵敏但突兀需要做插值动画才自然滞空时间怎么算起跳速度除以重力乘以 2也就是2 × 8.8 / 26 ≈ 0.68秒。这个数字很重要因为它决定了障碍在什么速度下跳得过去。障碍从 Z 55 到 Z 1要走的距离是 54 个单位。如果速度是 20那它走完需要 2.7 秒。玩家需要在障碍到达前大约 0.3 秒起跳才能在最高点附近越过它。速度再快人的反应就跟不上了。所以最大速度不要超过 26这是实测出来的上限。我在 Python 版里把换道做成了瞬间切换Scratch 版也是。如果想做得更顺滑需要引入一个目标轨道和一个当前轨道浮点数每帧做插值这一步留给你自己加。5. 常见问题与排查实录这两版做下来我攒了一堆坑。挑最有代表性的整理成两张表遇到问题先查表。5.1 Scratch 侧克隆体、变量、坐标现象最可能的原因解决方式障碍全叠在一起走Z 用了全局变量所有克隆体共用改成仅适用于当前角色新障碍凭空出现在屏幕中间本体位置不是 (0,0)克隆体继承了本体位置本体隐藏并固定在原点跑一会儿就不出障碍了克隆体超过 300 上限没被删除循环条件用重复执行直到 Z 小于 4障碍越跑越黑亮度是叠加的每帧都在加负值改用将亮度设为远处的障碍跑到天上去了y 轴符号搞反Scratch 向上为正公式改成地平线减高度乘比例高速时穿模判定用的是屏幕像素碰撞改用 Z 值区间判定分不清哪条道有障碍三条道间距太小把焦距调大或者把轨道间距改成 1.2第三条我要多说一句。克隆体泄漏是 Scratch 项目里最常见的性能杀手而且它不会报错只会让画面越来越卡。判断方法很简单在舞台上放一个显示变量接上克隆体数量这个侦测积木正常应该稳定在十几个一直往上涨就说明有泄漏。5.2 Python 侧安装、加载、运行现象最可能的原因解决方式python 不是内部或外部命令安装时没勾 Add to PATH卸载重装勾上那个选项ModuleNotFoundError: No module named pygame装的库和用的解释器不是同一个检查 VSCode 右下角选的解释器窗口一打开就无响应主循环里做了耗时操作没处理事件保证每帧都调用pygame.event.get()窗口拖动后物体瞬移dt 没有夹紧dt min(..., 1/30)TypeError: rect argument is invalid传了浮点数坐标用int()转换中文显示成方块字体不支持中文用SysFont指定中文字体名装了库但pip list里看不到装到了全局环境激活虚拟环境后再装想彻底重来一遍担心卸载不干净删掉.venv文件夹即可第六条那个中文字体问题我一开始以为是编码问题查了半天编码。其实是pygame.font.Font(None, 24)用的是默认字体那个字体里没有汉字字形所以画出来是方块。改用pygame.font.SysFont(microsoftyahei, 24)就好了Windows 上微软雅黑一定有macOS 上换成pingfang。5.3 调试的几个通用技巧第一先让画面动起来再让它变好看。我见过太多人卡在怎么画出好看的障碍上结果半个月都没跑通一个能玩的版本。先用pygame.draw.rect画个纯色方块能跑能跳了再换贴图。第二打印比看图快。怀疑 Z 值不对的时候别盯着屏幕看直接print(o.z)看数字是不是按预期递减。Scratch 里对应的做法是把变量勾上显示直接看舞台上的数值。第三速度调到 1 来排查。如果你怀疑碰撞判定有问题把速度设成 1一切慢下来问题会变得非常明显。这是我从做硬件调试的同事那学来的他管这叫慢速复现法。第四阶段式验证。别写三百行再运行。写完投影函数就画一个静止的方块验证写完地面就只跑地面每加一个模块就跑一次。这个习惯能帮你省下大量排查时间。6. 再往前走几步两版都跑通之后这个项目其实还有很多可以扩展的余地我挑三个我自己做过、成本又不高的说一下。6.1 用数据把难度曲线画出来游戏手感不能靠感觉调得看数据。我在 Python 版里加了一个简单的记录每次游戏结束时把存活时间、平均速度、死亡时的 Z 值写进一个列表跑几十局之后导出成文件。然后拿这套数据画图import matplotlib.pyplot as plt fig, ax plt.subplots(figsize(8, 4)) ax.plot(survive_times, markero) ax.set_xlabel(局数) ax.set_ylabel(存活时间秒) ax.xaxis.set_major_locator(plt.MaxNLocator(8)) plt.xticks(rotation45) plt.tight_layout() plt.savefig(difficulty.png, dpi140)这里顺便解决一个老问题横坐标太密集的时候标签会挤成一团。用MaxNLocator限制刻度数量再把标签旋转 45 度基本上就干净了。如果局数特别多直接按十局一组做平均比逐点画好看得多。看着这张图你能一眼判断出难度曲线是先易后难还是中间卡死。我第一版的图在 40 秒左右有一个明显的死亡高峰说明那个阶段的障碍组合不合理改了间隔参数之后就平了。6.2 用多进程跑一轮自动试玩如果要调参数手动玩几十局太费时间。写个简单的机器人在每次障碍进入判定区间时根据障碍类型自动决定跳还是不跳按同样的参数跑一千局。这种批量模拟正好适合用多进程因为每个进程互不干扰from multiprocessing import Pool with Pool(4) as p: results p.map(run_one_episode, range(1000))一千局跑完可能只要几秒钟然后统计平均存活时间。参数改一改再跑一遍两相对比比手动试玩靠谱得多。这个思路跟我前面提的用数据调难度是一套的。6.3 打包给别人玩pygame 项目打包用 PyInstaller一行命令pip install pyinstaller pyinstaller --onefile --windowed main.py--onefile打成单个可执行文件--windowed去掉那个黑乎乎的命令行窗口。生成的文件在dist目录里。要注意的是--onefile会把 Python 运行时一起打进去文件会有几十兆而且启动会慢一两秒因为它需要先解压到临时目录。如果只是给同一个局域网的人玩直接让对方装个 Python 和 pygame 更省事。打包最适合的场景是发给完全不想碰命令行的人比如家长或者老师。6.4 我的收尾体会从 Scratch 到 Python中间那道坎从来不是语法。语法就那么点东西if、for、赋值两个小时能讲完。真正的坎是你要开始自己管事情了帧率要自己管、对象什么时候消失要自己管、变量活多久要自己管、一帧之内先做什么后做什么也要自己管。Scratch 帮你挡掉了这些让你能专心玩逻辑Python 把这些全交还给你一开始会手忙脚乱。我个人的经验是别在切换的当天就写完整游戏。先从让一个方块在屏幕上匀速移动开始加上 dt加上事件处理加上退出逻辑这四件事做完你就已经跨过去了。之后往上加的东西都是在这个骨架上挂肉跟 Scratch 里做的事没有本质区别。最后分享一个小技巧我让学生把 Scratch 版的每一段积木用便签纸写下对应的 Python 写法贴在屏幕边上。写了大概三节课之后那堆便签就可以撕掉了。因为到那个时候他们已经不需要翻译了。