
第一次做3D跑酷我用的是Scratch屏幕上那条不断向下滚的跑道其实是十几个克隆体在轮流换位演出来的。后来觉得不过瘾想加真实的透视、想稳住60帧、想让画面有纵深明暗我就用Python重写了一遍。两个版本并排放着跑我才发现从Scratch到Python根本不是换一门语言这么轻松而是整套坐标思维、渲染思路和调试方法的重建。这篇就聊聊我这两版3D跑酷是怎么落地的Scratch版怎么用克隆体装出三维感Python版怎么从零搭起一个能跑的游戏循环两边的核心机制怎么互相迁移以及那些文档里不会写、只有真跑过才知道的坑。不管你是刚学完Scratch顺序分支循环的新手还是已经能折腾Python基础语法、正想找个项目练手的同学应该都能从里面抠出点能直接抄的东西。1. 为什么同一款3D跑酷我要写两个版本1.1 Scratch能做出的3D本质是算出来的假象很多人对Scratch的印象还停留在贪吃蛇、九九乘法表、冒泡排序这些二维小项目上觉得它做不了3D。其实这个判断对一半。Scratch的舞台本身只有x和y两个坐标轴没有真正的z轴和相机所以它永远画不出真实的三维模型。但3D跑酷这种游戏有个特殊性它的场景是高度规则的——一条直路两侧是墙或者护栏障碍物沿着路排布镜头永远朝前。这种场景用伪3D投影就能骗过眼睛而且骗得很好。所谓伪3D就是我在脑子里维护一套虚构的三维坐标x左右、y上下、z前后每一帧把这些三维点用公式换算成舞台上的二维屏幕坐标。近的东西放大、往屏幕下方和两侧推远的东西缩小、往屏幕中心收透视感就出来了。Scratch负责的只是最后那一步把算出来的二维坐标画出来真正的三维运算全靠变量和运算积木硬算。这也是为什么Scratch版的学习价值特别高它逼着你把3D投影的数学亲手推一遍而不是调个引擎函数就完事。等你以后真的用上三维引擎会发现自己对相机、焦距、深度这些概念的理解比直接上手引擎的人扎实得多。1.2 Python补上的三块短板转到Python之后最直观的变化是三个性能、渲染能力和工程化。性能上Scratch每一帧的克隆体数量、运算复杂度都有隐形上限跑道段一多、障碍物一密帧率就往下掉画面开始卡顿撕裂。Python这边配合pygame或者GPU渲染几百个多边形、上千个粒子都能稳在60帧跑酷的速度感才能拉满。渲染能力上Scratch只能画矢量精灵和简单图形做不了实时的光照、雾效、动态阴影。Python里哪怕是用pygame这种偏向二维的库也能靠手写多边形填充和透明度做出渐变雾效如果换成基于Panda3D的Ursina引擎那直接就是真三维模型、材质、光照都是现成的。工程化上Scratch项目一复杂就变成一堆积木缠在一起改一个逻辑要翻半天Python有模块、有类、有版本管理代码可以拆成 camera.py、road.py、player.py谁改哪儿一目了然。这三块补上之后我才敢往游戏里加真正的玩法深度。1.3 两条路线到底该怎么选这里给一个我自己的判断标准避免你纠结。如果你是想理解3D背后的原理、想用最低门槛快速出效果、想给小孩或者零基础的朋友演示那Scratch版是更好的起点它的反馈快、调试直观积木拖错了马上能看见。如果你的目标是把游戏做成一个能持续迭代、能发布分发、能加复杂玩法的作品或者你想借这个项目把Python的类、循环、事件、时间步进练熟那就走Python路线。需要说明的是这两条路不是替代关系。我现在的习惯是先用Scratch把玩法和数值调爽确认手感对了再把逻辑一比一翻译到Python里。这样能省掉大量在Python里反复改参数、反复重启的时间因为Scratch改一个变量的值只用点两下。下面第2章和第3章我就分别拆解这两版的实现。2. Scratch版3D跑酷克隆体加伪3D投影2.1 舞台坐标系与假三维的换算公式先把基础坐标搞清楚。Scratch舞台宽480、高360中心点是(0,0)x范围是-240到240y范围是-180到180。我要虚构一套三维世界物体有worldX左右偏移、worldY离地高度、worldZ离相机的距离。相机默认放在worldZ等于0的位置朝worldZ增大的方向看。核心换算只有三行。设焦距F是一个常数比如300你可以理解成镜头到画面的距离值越大画面越平、越小透视越夸张。对每个三维点先算相对深度 dz worldZ - camZ如果dz小于一个很小的值就说明这个点在相机后面直接不画。然后是缩放比 scale F / dz。最后落到屏幕坐标屏幕x worldX × scale屏幕y worldY × scale 再加上一个地平线偏移。这套公式和Python版是一模一样的只是写法不同你在Scratch里用运算积木拼出来即可把F、camZ、scale都设成全局变量方便后面调。提示F这个值强烈建议边拖边看。F设小了跑道近处会撑满屏幕、远处缩成一个点透视很猛但容易晕F设大了整个画面像压扁了速度感全无。我最后稳定在300到360之间配合跑道宽度用。2.2 跑道分段与克隆体的滚动复用Scratch里没法一次画一整条无限长的路我的做法是把路切成若干段用克隆体来表示每一段跑过去的段就回收重用。具体来说新建一个跑道段角色造型就是一个横向的梯形或者矩形给它一个私有变量myZ表示这段离相机多远。克隆体生成的时候让myZ等于序号 × 段长比如段长取0.8第0段myZ0第1段myZ0.8依次往后。每一帧要做的是让所有段的myZ都减去本帧前进的距离。这里有个关键技巧Scratch虽然不直接给你每帧时间但可以用计时器积木做出时间步长。做法是新建变量上一帧时间和dt循环开头先dt 计时器 - 上一帧时间再上一帧时间 计时器。这样dt就是这一帧真实过去的时间秒数用速度 × dt去移动所有段帧率高低游戏速度都一致不会因为电脑卡一下玩家角色就瞬移。每个克隆体更新画面时先算出它相对相机的深度跑一遍2.1里的缩放公式得到屏幕y和缩放比然后将大小设为(基础大小 × scale)、移到x: 0 y: 算出来的屏幕y。当某段的myZ跑到相机后面比如小于0.2就把它重新放回最远处形成无限循环的跑道。这样只要十几个克隆体就能造出一条看起来永无止境的路。2.3 障碍生成、图层排序与碰撞判定障碍物我复用了同一套投影逻辑。每隔一段时间在远处的随机车道左、中、右三条生成一个障碍克隆体给它myZ和车道x两个私有变量。每帧对它做投影算出屏幕坐标和缩放比同时根据缩放比调整大小这样它从远处小点逐渐变大冲到眼前视觉上就对了。这里有个Scratch特有的难点图层排序。Scratch没有设置图层号的积木只有移到最前面和移到最后面。伪3D场景里近的物体必须盖住远的物体否则会出现远处障碍压在近处路面上这种穿帮。我的解决办法是每一帧先按myZ从大到小也就是从远到近把跑道段和障碍物排序然后依次执行移到最前面。因为后执行的在更前面所以最近的最后移到最前图层自然正确。排序用简单的插入排序写在循环里就行数量不多开销可以接受。碰撞判定我不建议直接用碰到角色积木因为它是按精灵轮廓判定的缩放之后误差很大经常看着没碰到却判定撞了。更稳的做法是自己在三维空间里做矩形判定玩家固定在某条车道上、在一个固定的worldZ位置当某个障碍的myZ逼近这个位置、且车道相同、且玩家此时不在跳跃高度之上就算碰撞。这种自己算的判定虽然要多写几行但行为完全可控不会有莫名其妙的判定。2.4 用亮度特效应造出纵深和速度感Scratch的外观积木里有个亮度特效量程是-100到100负值让角色变黑正值变白。这个本来是用在角色明暗上的功能被我拿来做了雾效越远的跑道段和障碍物亮度越接近-100发黑越近越接近0。具体可以设成亮度 -100 × (myZ / 最大可视深度)再夹紧到-100到0之间。这样一来远处的路自然溶进背景色里画面就有了空气透视纵深感比单纯靠缩放强得多。速度感还靠另外两个小手段。一是让路面上的纹理或者车道线随速度滚动因为跑道段本身就是滚动的把段上的造型做成一段路面加一道横向条纹滚动起来视觉参考点就很明确。二是加一点点屏幕抖动或者相机的轻微上下浮动高速时幅度大一点但别过头不然会晕。我试过给跑道两侧加飞驰而过的灯柱克隆体那个奔跑的爽感提升非常明显成本却很低。3. Python版3D跑酷从环境到渲染循环3.1 Python安装与开发环境配置的完整流程先说环境这一块是新手最容易卡住的地方我把整套流程写清楚。到Python官网下载安装包注意安装界面底部那个Add Python to PATH一定要勾上不勾的话后面在命令行里敲python会提示找不到命令。装完之后打开终端输入python --version能打印出版本号就说明装好了。接着是虚拟环境这是很多人跳过、后面却吃大亏的一步。不同项目的依赖版本会打架我习惯每个项目单独建一个。进入项目文件夹执行下面几条命令# 创建虚拟环境会生成一个 .venv 文件夹 python -m venv .venv # Windows 激活 .venv\Scripts\activate # macOS / Linux 激活 source .venv/bin/activate # 安装本项目的依赖 pip install pygame编辑器我用VSCode和PyCharm都试过。VSCode的话装官方的Python扩展然后按CtrlShiftP搜Python: Select Interpreter选中刚才.venv里的解释器这样它才知道用哪个环境跑代码。PyCharm则在新建项目时直接指定解释器为已有的虚拟环境配好之后它会自动识别依赖。两边的坑都集中在解释器选错了表现是代码里import报红、运行报ModuleNotFoundError排查时先看右下角或者设置里的解释器路径对不对。注意如果装完之后pip命令用不了可以改用python -m pip install pygame这种写法能绕开绝大多数PATH问题。Linux上如果报权限错误不要用sudo去装到系统里建虚拟环境才是正路。3.2 渲染方案选型pygame还是UrsinaPython里做3D跑酷有两条明显不同的路我把对比列出来方便你按目标挑。方案本质上手难度画面上限适合场景pygame二维库手写伪3D投影中等中靠多边形和雾效想亲手搞懂3D原理、追求轻量Ursina封装Panda3D的真三维引擎较低高支持模型光照想快速出真3D效果、加复杂关卡我这次的Python版选的是pygame。原因很直接我前面在Scratch里已经把伪3D投影的公式推熟了换成pygame其实是同一套公式换语言能一比一迁移学习连续性最好。而且pygame轻量启动快打包出来的体积也小。如果你完全没碰过投影、又想让画面直接是模型和光照那Ursina更省事几行代码就能加载模型、加相机、加灯光但你会跳过三维投影这一课。两种都行看你想要理解还是快速出效果。3.3 相机模型与三维投影函数的实现pygame版的核心就是我写的一个投影函数它和Scratch里的缩放公式完全一致。先定义常量import pygame W, H 960, 600 HALF_W, HALF_H W // 2, H // 2 FOV 500 # 焦距越大透视越平缓 CAM_HEIGHT 1.6 # 相机离地高度世界单位 ROAD_W 3.2 # 路面半宽 LANE_X (-1.9, 0.0, 1.9) # 三条车道的中心x投影函数接收一个世界坐标点和相机返回屏幕坐标和缩放比。这里我统一约定y轴向上为正、地面在y等于0def project(x, y, z, cam): dz z - cam.z # 相对深度 if dz 0.2: # 在相机后方或太近不画 return None scale FOV / dz # 透视缩放比 sx HALF_W (x - cam.x) * scale sy HALF_H - (y - CAM_HEIGHT) * scale return sx, sy, scale跑道的画法是把路面切成若干纵向的段每段用左右两条边界线投影出四个顶点然后pygame.draw.polygon填充成一个四边形。为了让路面有纵深感我按段离相机的远近给每段设置不同的亮度远的暗、近的亮用颜色数值插值实现这就是pygame版的雾效对应Scratch里的亮度特效。def draw_road(screen, cam, seg_len1.2, seg_count40): for i in range(seg_count): z_near cam.z i * seg_len z_far z_near seg_len # 每段四个角左右 × 近远 corners [] for z in (z_near, z_far): left project(-ROAD_W, 0, z, cam) right project(ROAD_W, 0, z, cam) if left is None or right is None: corners [] break corners.append(left) corners.append(right) if len(corners) 4: # 越远越暗模拟空气透视 depth_ratio min(i / seg_count, 1.0) shade int(200 - 150 * depth_ratio) color (shade, shade, shade 20) poly [(corners[0][0], corners[0][1]), (corners[1][0], corners[1][1]), (corners[3][0], corners[3][1]), (corners[2][0], corners[2][1])] pygame.draw.polygon(screen, color, poly)相机我是这样处理的cam.z随玩家前进不断增大cam.x平滑地追上玩家所在的x做出跟车效果。这里必须用平滑跟随而不是硬跟随硬跟随画面会随着切车道而剧烈横跳很晕。我用的是一阶插值cam.x (target_x - cam.x) * 0.15这个系数0.15是调出来的太大会抖、太小会粘。3.4 游戏主循环、障碍生成与跳跃手感主循环是Python版的骨架时间步进和Scratch版一个思路——用真实的时间差驱动一切。pygame里用clock.tick(60)来限制帧率它返回距上一帧的毫秒数除以1000就是秒import random, sys pygame.init() screen pygame.display.set_mode((W, H)) clock pygame.time.Clock() class Cam: def __init__(self): self.x 0.0 self.z 0.0 class Player: def __init__(self): self.lane 1 # 初始在中间车道 self.y 0.0 # 离地高度 self.vy 0.0 self.alive True class Obstacle: def __init__(self, z, lane): self.z z self.lane lane cam, player Cam(), Player() obstacles [] speed 8.0 # 前进速度世界单位每秒 gravity -26.0 # 重力加速度 jump_v 9.5 # 起跳初速度 spawn_timer 0.0 running True while running: dt clock.tick(60) / 1000.0 for e in pygame.event.get(): if e.type pygame.QUIT: running False if e.type pygame.KEYDOWN and player.alive: if e.key in (pygame.K_LEFT, pygame.K_a): player.lane max(0, player.lane - 1) if e.key in (pygame.K_RIGHT, pygame.K_d): player.lane min(2, player.lane 1) if e.key in (pygame.K_SPACE, pygame.K_UP) and player.y 0.001: player.vy jump_v # 前进 相机跟随 cam.z speed * dt cam.x (LANE_X[player.lane] - cam.x) * 0.15 # 跳跃物理 player.vy gravity * dt player.y player.vy * dt if player.y 0: player.y 0 player.vy 0 # 障碍生成时间越久越密 spawn_timer - dt if spawn_timer 0: spawn_timer max(0.45, 1.1 - cam.z * 0.002) obstacles.append(Obstacle(cam.z 42.0, random.randint(0, 2))) # 障碍推进与回收 for ob in obstacles: pass # 碰撞玩家在相机前方固定距离处 player_z cam.z 4.0 for ob in obstacles: if abs(ob.z - player_z) 0.9 and ob.lane player.lane and player.y 1.2: player.alive False obstacles [ob for ob in obstacles if ob.z cam.z] # 回收跑过去的 screen.fill((18, 18, 26)) draw_road(screen, cam) # 障碍与玩家绘制略逻辑同上投影 pygame.display.flip() pygame.quit() sys.exit()上面这段是能直接跑起来的骨架障碍和玩家的绘制我省了逻辑就是调project拿到屏幕坐标后画矩形或贴图。这里重点讲几个手感参数的来由。重力取-26、跳跃初速度取9.5是我反复试出来的跳跃总时长大约是2×9.5/26 ≈ 0.73秒能跨过约0.73秒内前进的距离配合障碍间距刚好够闪避。如果重力太小角色会飘手感发飘像在月球重力太大则跳跃断得太快玩家来不及反应。速度speed8配合障碍生成间隔前期1.1秒一个、后期最快0.45秒一个难度曲线是随距离平滑上升的这个公式max(0.45, 1.1 - cam.z * 0.002)比每隔固定距离加难度要顺滑。提示碰撞判定里我用player.y 1.2来判断没跳过障碍高度。这个1.2就是障碍物的高度你调障碍模型高度时一定要同步改这个值否则会出现明明跳过去了却撞上或者贴着障碍穿过去两种极端这是新手最常见的bug。3.5 帧率稳定、打包与分发跑起来之后第二步是让它稳。开放世界游戏最怕掉帧跑酷这种高速游戏尤其明显。我做了两件事一是所有运动都用dt驱动绝不在循环里写每帧移动固定距离这样60帧和144帧的机器上速度一致二是障碍物列表做了回收跑过相机的即时删掉避免列表无限增长导致越来越卡。这两条看着简单但很多我见过的练手项目都栽在上面跑五分钟就开始一顿一顿。想把游戏发给别人玩用PyInstaller打包最省事pip install pyinstaller pyinstaller -F -w game.py-F是打包成单个可执行文件-w是运行时不弹命令行窗口。打完在dist文件夹里就是成品。要注意如果你用了外部图片、音效文件得用--add-data把它们一起打进去否则别人运行会报找不到资源。我第一版忘了这茬发出去朋友一打开就黑屏排查了半天才发现是打包没带资源。4. 两版核心机制怎么互相迁移4.1 Scratch与Python概念对照表把Scratch版翻成Python版的时候我整理了一张对照表能省大量找对应关系的时间。核心思想是两边其实都在做同一件事只是工具不同。功能Scratch做法Python做法三维投影变量运算积木拼公式project()函数返回屏幕坐标无限跑道克隆体回收重用按z分段绘制越界即不画图层排序移到最前面按远近顺序执行绘制顺序天然就是图层顺序帧率无关运动计时器差值算dtclock.tick(60)返回毫秒雾效/纵深亮度特效调负值颜色数值随深度插值变暗碰撞检测自己算矩形范围世界坐标AABB判定相机跟随移动所有物体反向模拟cam.x平滑追逐玩家x看懂这张表你会发现真正变的是实现手段不变的是游戏逻辑本身。跑酷的核心就三条前进、变道、闪避任何语言里都是这三件事。4.2 手感调参速度、加速度和跳跃曲线不管哪个版本跑酷好不好玩七成看手感三成看画面。我把调参经验集中说一下。第一是速度曲线不要一上来就是最高速玩家需要时间进入状态。我的做法是把速度随时间缓慢上升Scratch里用速度 基础速度 已跑距离 × 系数Python里同理让玩家在不知不觉中越跑越快刺激感就来了。第二是变道的过渡绝对不能瞬间横移。Scratch里是让x坐标在一两帧内插值过去Python里就是前面说的cam.x平滑跟随再加玩家自身的插值。这里有个细节玩家视觉上的横向位置其实可以固定靠相机平移来制造切车道的感觉这样画面更稳因为动的是世界而不是角色这也是很多赛车游戏的标准做法。第三是跳跃曲线的形状。直线上升下降的跳跃是没灵魂的要做出跳起慢、下落快的重力感就得让竖直速度被重力持续削减。这套物理在Scratch里用变量vy和每帧vy vy 重力 × dt、y y vy × dt就能实现和Python里一模一样。重力取负值起跳时给vy一个正初速度曲线自然就对了。4.3 常见坑与排查速查表下面这张表是我两版加起来踩过的坑按现象和原因整理遇到问题可以直接对号入座。现象大概率原因解决方向远处物体压住近处物体图层/绘制顺序不对Scratch按远到近移到最前Python调整绘制顺序电脑一卡角色瞬移没做帧率补偿引入dt所有位移乘dt碰撞判定忽灵忽不灵用精灵轮廓判定改自己算的矩形/世界坐标判定跑一会儿变卡克隆体/列表没回收越界对象及时重置或删除画面晕相机硬跟随或透视过猛相机平滑插值、调大焦距F打包后黑屏资源文件没打包进去PyInstaller加--add-data跳跃感觉飘重力太小增大重力绝对值、同步调初速度我在Scratch里还遇到过一个更隐蔽的问题克隆体数量超过一定值后某些克隆体的初始化脚本会延迟执行表现为跑道偶尔缺一段。解决办法是别在当作为克隆体启动时里做复杂运算只做最简单的变量赋值把重活放到主循环里按状态处理。这个坑文档里不会写纯粹是跑多了才发现的。5. 常见问题与排查技巧实录5.1 Scratch侧克隆体的初始化顺序陷阱Scratch的克隆体有个很容易被忽略的行为当你一次性生成很多克隆体时它们的启动脚本执行顺序是不确定的而且如果在启动脚本里还去读取全局变量很可能读到的是别的克隆体改过的值。我最早的跑道版本就因为这个偶尔出现某一段跳到了错误的高度上画面闪一下。我的处理办法是把克隆体当成笨工人每个克隆体启动时只做两件事——把自己编号记下来、把自己放到初始位置其他所有逻辑全部交给一个统一的主循环去遍历处理。主循环里按编号从小到大依次更新每段的位置顺序完全确定就不会有随机跳变。这个把复杂的初始化踢给主循环的思路在Python里其实就是先创建对象再在update里统一驱动的模式两边是通的。另外提醒一句Scratch里遍历克隆体最好用一个存编号的列表来管理别指望当作为克隆体启动时能保证顺序。5.2 Python侧ModuleNotFoundError和解释器错位Python新手最常见的报错就是ModuleNotFoundError: No module named pygame。这个报错九成不是你没装而是装的解释器和跑的解释器不是同一个。你系统里可能有好几个Python官网装的那个、系统自带的那个、某个软件捆绑的那个。你在虚拟环境里pip装了pygame但编辑器里用的是系统解释器那自然找不到。排查步骤很固定在终端里执行python -c import sys; print(sys.executable)看它打印的路径是不是你项目里的.venv再在编辑器里确认选的解释器路径和它一致。一致了还报错就执行python -m pip list看pygame在不在列表里不在就重装。这套流程我写成了习惯基本三分钟能定位所有环境类问题。如果你把Python装在Linux上记得别用系统级的pip去污染全局环境虚拟环境永远是首选。5.3 一个通用的调试套路两版都适用最后分享我调这类游戏的一个通用方法把游戏拆成输入、更新、渲染三层哪一层出问题就单独测哪一层。输入层就是键盘事件我习惯在改逻辑前先在屏幕上把按键状态打出来看确认按键被识别了。更新层是物理和逻辑我会临时画一些调试线——比如在Scratch里用画笔画一条表示玩家碰撞范围的红线在Python里用pygame.draw.rect画出玩家的碰撞盒肉眼确认范围和位置对不对。渲染层出问题通常是投影公式写错这时候把相机参数固定成一组已知值拿笔在纸上算一遍某个点的屏幕坐标和程序算出来的对比差在哪儿一眼就看出来了。这个调试套路的好处是它和语言无关。Scratch里能用的固定参数手算校验Python里照样能用反过来Python里那套打印中间变量的做法在Scratch里换成把变量显示在舞台上效果一样。我从Scratch换到Python最省时间的地方就是这套方法论可以直接搬。两个版本我都留着Scratch那版当教学demoPython那版当持续迭代的主力。如果你手头正好也有个跑酷的点子我建议先把Scratch版跑通把跑道、障碍、跳跃这三件事的手感在一两天内调爽再动手写Python。反过来一上来就啃Python和三维投影很容易卡在环境配置或者某个投影公式上热情一半就耗没了。真要给个最小起步点Scratch先做出十个克隆体拼的滚动跑道Python先把project函数写出来并画对一条路这两步迈过去剩下的都是水到渠成的事。