news 2026/9/18 3:11:40

从Scratch到Python:3D跑酷项目打通积木与代码

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从Scratch到Python:3D跑酷项目打通积木与代码

社区活动室那台用了五年的笔记本,是我第一次带着一群十来岁的孩子做游戏的起点。第一节课在 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,但不要用它。装官方的安装包装完之后,命令行里对应的是python3pip3。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 扩展,然后按Ctrl+Shift+P输入 "Python: Select Interpreter",选刚才那个.venv里的解释器。这一步不选,编辑器用的是全局环境,你装的库它看不见,代码下面会全是黄色波浪线。PyCharm 的流程类似,在 Settings 里的 Project Interpreter 选虚拟环境。

装库。这个项目我用 pygame:

pip install pygame

国内网络如果慢,加个镜像参数就行,不加也能装,就是等一会儿。

3.2 选型:pygame 伪 3D 和 Ursina 真 3D 怎么选

Python 做 3D 有两条路,我把它们的差别列出来,你按需求挑。

方案底层学习曲线适合做什么我的建议
pygameSDL2平缓,会 Python 基础就能上2D 游戏、伪 3D 效果推荐先走这条
UrsinaPanda3D中等,需要理解实体和相机真 3D 场景、第一人称熟练后再上
moderngl / PyOpenGLOpenGL陡峭,要懂着色器自定义渲染不建议入门碰

我选 pygame 打头阵,理由很直接:它跟 Scratch 版的思路是一一对应的。Scratch 里你手算缩放,pygame 里也是手算缩放,中间没有黑盒。而 Ursina 给你一个Entity(model='cube'),它会自己处理投影和相机,学起来爽,但你其实没搞懂背后发生了什么,换个引擎又得重来。

还有个实际考虑:pygame 的依赖非常好装,一个 pip 命令搞定。Panda3D 的依赖体积大,在配置一般的机器上编译时间很长。带课的时候,装环境的时间越短越好。

等你把 pygame 版写完,再去看 Ursina,会发现它做的事情你全都懂,只是它帮你把代码写好了。这个顺序才是对的。

3.3 透视投影:一行公式把 3D 压到 2D

pygame 的坐标系跟 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 = 420CAM_Y = 1.6HORIZON = 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, key=lambda 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_radius=4) 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_radius=8) 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 * t,t 是游戏时间。跑下来发现前 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, marker="o") ax.set_xlabel("局数") ax.set_ylabel("存活时间(秒)") ax.xaxis.set_major_locator(plt.MaxNLocator(8)) plt.xticks(rotation=45) plt.tight_layout() plt.savefig("difficulty.png", dpi=140)

这里顺便解决一个老问题:横坐标太密集的时候标签会挤成一团。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,中间那道坎从来不是语法。语法就那么点东西,iffor、赋值,两个小时能讲完。真正的坎是你要开始自己管事情了:帧率要自己管、对象什么时候消失要自己管、变量活多久要自己管、一帧之内先做什么后做什么也要自己管。Scratch 帮你挡掉了这些,让你能专心玩逻辑;Python 把这些全交还给你,一开始会手忙脚乱。

我个人的经验是,别在切换的当天就写完整游戏。先从"让一个方块在屏幕上匀速移动"开始,加上 dt,加上事件处理,加上退出逻辑,这四件事做完,你就已经跨过去了。之后往上加的东西,都是在这个骨架上挂肉,跟 Scratch 里做的事没有本质区别。

最后分享一个小技巧:我让学生把 Scratch 版的每一段积木,用便签纸写下对应的 Python 写法,贴在屏幕边上。写了大概三节课之后,那堆便签就可以撕掉了。因为到那个时候,他们已经不需要翻译了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 3:10:41

鸿蒙上跑通open_route_service:从环境配置到路径规划全指南

从“标题党”到“真适配”&#xff1a;这次我在鸿蒙上真正跑通了 open_route_service先说结论&#xff1a;open_route_service 这个 Flutter 三方库&#xff0c;在鸿蒙&#xff08;HarmonyOS NEXT / OpenHarmony&#xff09;环境下&#xff0c;可以完成一次“真实可用”的全球路…

作者头像 李华
网站建设 2026/9/18 3:09:17

TB67S531FTG与PIC18LF46K42的高性能步进电机驱动方案解析

在工业现场和机器人项目里&#xff0c;步进电机控制永远是个绕不开的话题。42步进电机配合驱动板几乎是桌面级设备和自动化改造的标配&#xff0c;但真正要做到低速平稳、高速不丢步、同时还不发热&#xff0c;靠的是驱动器和控制器的搭配默契。这次我用的方案是东芝的 TB67S53…

作者头像 李华
网站建设 2026/9/18 3:09:13

WPF组态管道立体感与流体粒子动画实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 3:08:49

TB67S531FTG与STM32L073RZ步进电机失步排查与驱动设计

有个做小型机械臂的朋友前阵子找我&#xff0c;说他的两相双极步进电机在低速时稳稳当当&#xff0c;一加速到 200 RPM 就开始丢步&#xff0c;抓取位置每次差个一两度&#xff0c;改程序改到怀疑人生。他用的方案是 TB67S531FTG 配 STM32L073RZ——这套组合在工业设备和小型机…

作者头像 李华
网站建设 2026/9/18 3:07:38

若群聊 Agent 只点名发言,TaoToken 如何记清 Token 消耗

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华