用Python和Ursina开发3D接方块游戏:从入门到打包全指南

发布时间:2026/10/13 2:29:50

用Python和Ursina开发3D接方块游戏:从入门到打包全指南 最近有人问我用Python写3D游戏到底现不现实。我的回答是你把Ursina装上写十行代码就能看到一个旋转的3D方块这已经比十年前很多独立的3D引擎入门门槛低了。我最近把一个用Pygame写的2D接方块小游戏整个改成了Ursina版纯Python代码不到三百行能玩、能计分、能发出来让大家评论。这篇文章不是照抄文档而是把我从项目设计到功能修改、再到调试打包的全过程摊开来讲。你会看到Ursina的核心机制、一个接方块游戏的完整代码还有我改了三版才踩平的坑。如果你有点Python基础又一直想试试3D游戏开发这篇可以直接当行动手册用。1. 为什么选Ursina而不是Unity或Pygame1.1 Ursina其实是Panda3D的“易用外壳”很多人第一次听说Ursina会以为它又是一个玩具引擎。它不是一个独立引擎而是基于Panda3D渲染引擎的Python封装库。Panda3D是卡耐基梅隆大学开源出来的C引擎自带场景管理、渲染管线、碰撞系统、音频系统本身就有完整的3D工业级能力但它的原生API用起来很繁琐要自己创建SceneGraph、DataGraph、CollisionHandler等等新手会被这些概念劝退。Ursina干的活就是把这些东西藏起来让你用最简单的对象思维去写游戏。我举一个感受最直接的例子在Panda3D里往场景加一个带纹理的立方体可能要写十几行样板代码在Ursina里一行就行Entity(modelcube, texturewhite_cube, scale(1,2,1), position(0,1,0))如果你用Unity得先建工程、放相机再拖一个Cube进去调节Transform组件项目文件一多新手经常不知道哪个是哪个。Ursina把所有东西变成代码里的普通对象这对习惯了写脚本的人来说非常舒服。所以它适合做教学演示、游戏原型、课程设计、Game Jam作品甚至一些算法仿真和机器人3D预览。1.2 Python做3D游戏不会卡吗这个疑问我几乎每次都会被问到。说实话关键看底层是谁在渲染。Ursina的底层是Panda3DPanda3D是一个C引擎Python只是作为业务逻辑接口。也就是说你写的Python代码只负责任务逻辑、每帧计算真正的顶点变换、纹理采样、深度缓冲都是C部分完成的。只要不疯狂创建上万个独立实体做一个低模风格的小型3D游戏60帧是很轻松的事。我做这个项目之前用Pygame写2D打砖块Pygame本质是2D精灵渲染没有Z轴没有深度测试想做一个“挡板可以在三维空间里移动、掉落物从头顶往下落”的游戏就非常别扭。强行用顶点投影模拟3D等于自己在写软渲染器效率低还容易出透视错误。Ursina给了我一条捷径这也是为什么这个项目在标题里特意带了个“修改”——从2D迁移到3D不是重写玩法而是把玩法塞进一套真正有深度的坐标系里。1.3 什么时候不应该用Ursina我也得说点实话。Ursina不适合做大型开放世界因为它不是面向性能和资源流式加载设计的不适合需要复杂物理模拟的项目它没有Havok或PhysX级别的内容不适合做需要严格网络同步的大规模在线游戏。还有一点它的文档和社区比主流引擎小很多很多问题要自己翻源码。但对于“快速验证一个3D玩法”“给朋友展示一个效果”“作为毕业设计的可视化部分”Ursina的优势足够明显。2. 环境搭建与基础概念先跑通你的第一个方块2.1 安装方式和Python版本选择安装步骤非常简单一行命令pip install ursina但版本有讲究。Ursina依赖Panda3DPanda3D对不同Python版本的支持节奏不一样。我实测下来Python 3.10是最稳的选择3.11大部分情况可以3.12在一些机器上会出现找不到预编译包或启动崩溃。如果你已经装了Python 3.12建议不要硬扛装一个3.10的解释器虚拟环境省去很多编译阶段的麻烦。装完不要急着写游戏先创建一个test.pyfrom ursina import * app Ursina() app.run()运行后弹出3D窗口并保持住就说明环境没问题。注意两点第一一定要在.py文件里运行不要放在Jupyter Notebook或者Python交互式命令行里跑因为app.run()会进入主循环并阻塞当前线程在很多交互环境中窗口不会正常刷新第二虚拟机会出现OpenGL渲染异常窗口黑屏是常态不要白费力气配置直接在本机上跑。2.2 Entity场景里的所有东西一视同仁Ursina的世界里玩家是Entity地面是Entity光源是Entity连相机都是Entity。它的设计继承了ECS基因但做了极大简化。创建一个实体时常用的参数有model内置几何体比如cube、sphere、quad、planecolor颜色例如color.orangescale缩放可以传数字或三元组例如(1,2,1)表示X轴1、Y轴2、Z轴1position三维坐标原点通常在场景中心rotation欧拉角三元组texture贴图可以填内置资源名或文件路径collider碰撞体后面讲碰撞时再展开下面这个小场景能放出一个绿色地面和橙色方块from ursina import * app Ursina() ground Entity( modelplane, scale(10, 1, 10), colorcolor.green ) player Entity( modelcube, colorcolor.orange, scale(1, 2, 1), position(0, 1, 0) ) app.run()这里ground放在原点player的y坐标设成1是因为它的高度是2底部才能刚好压在地面上。如果你把player放在y0它会和地面模型重叠一半后续做物理或碰撞时容易出问题。2.3 坐标、update和input游戏运行的三块基石Ursina使用Y轴向上的三维坐标系X向右Y向上Z朝前。窗口中央是原点。做移动逻辑之前先把这个坐标系装进脑子不然写出来的方向会经常反。游戏运行后每一帧都会自动调用update()函数默认大概每秒60次。所有需要不断变化的逻辑比如位置移动、渐变、碰撞检测都可以放在这个函数里。而input(key)函数会在按键触发的瞬间执行一次适合跳跃、发射、交互这类瞬时行为。下面是一个简单演示def update(): speed 0.1 player.x (held_keys[d] - held_keys[a]) * speed player.y (held_keys[e] - held_keys[q]) * speed def input(key): if key space: print(你按了空格)这里的held_keys字典很有意思按着d返回True松开返回False。Python的布尔值可以当成1和0参与算术计算所以held_keys[d] - held_keys[a]会得出1、-1或0正好用来确定移动方向。使用input处理连续移动会非常别扭因为它只触发一次要连按很多次才能走一段。这个经验是我修改第一版时摸索出来的连续移动用held_keys点按动作用input手感才正常。3. 核心项目做一个3D接方块小游戏3.1 游戏规则和可运行代码这个项目我给它起名叫“SkyCatcher”玩法很简单玩家控制一块扁平的蓝色挡板在地面上前后左右移动天上不断掉落彩色方块和球体。挡板接住一个加一分漏掉一个扣一条命三条命用完游戏结束并显示总分。下面是完整可运行的最小版本。我尽量保留注释方便你直接抄from ursina import * import random app Ursina() player Entity( modelcube, colorcolor.blue, scale(1.2, 0.4, 2.4), position(0, 0.2, 0), colliderbox ) ground Entity(modelplane, scale20, texturegrass) lives 3 score 0 score_text Text(textScore: 0, position(-0.85, 0.45), scale2) lives_text Text(textLives: 3, position(-0.85, 0.35), scale2) def spawn_obstacle(): obs Entity( modelrandom.choice([cube, sphere]), colorrandom.choice([color.red, color.yellow, color.magenta]), scalerandom.uniform(0.4, 0.8), position( random.uniform(-8, 8), random.uniform(12, 16), random.uniform(-5, 5) ), colliderbox ) obs.vy random.uniform(0.03, 0.06) def fall(): if obs.y -3: obs.y - obs.vy else: destroy(obs) lives - 1 update_display() if lives 0: end_game() obs.fall fall invoke(fall, delay0) def update(): global score player.x (held_keys[d] - held_keys[a]) * 0.12 player.z (held_keys[w] - held_keys[s]) * 0.12 player.x clamp(player.x, -9, 9) player.z clamp(player.z, -6, 6) for obs in scene.entities: if hasattr(obs, fall) and obs.intersects(player).hit: score 1 destroy(obs) update_display() break def update_display(): score_text.text fScore: {score} lives_text.text fLives: {lives} def end_game(): score_text.text fGame Over! Final Score: {score} lives_text.text for obs in scene.entities: if hasattr(obs, fall): destroy(obs) def tick(): spawn_obstacle() invoke(tick, delayrandom.uniform(0.5, 1.2)) invoke(tick, delay1) app.run()把这段代码存成main.py运行就能开始玩了。注意我这里把“每个掉落物的下落逻辑”挂到了实体自己的fall属性上而不是全部塞进全局update。这样每个实体有独立的下落速度游戏结束后能统一用hasattr(obs, fall)找到所有活动中的障碍并销毁。3.2 关键机制逐行拆解碰撞、生成循环和UI这个游戏最核心的部分是碰撞检测。obs.intersects(player).hit是Ursina最常用的碰撞判断写法。它要求两个实体都已经设置collider属性否则永远返回False。我在player和obs上都加了colliderbox所以能正确触发。类型上直接看模型形状就好方块和挡板用box球虽然视觉上是圆的但为了简单也用box判定精度稍粗但对接方块游戏完全够用。生成循环用invoke而不是while循环实现。invoke(tick, delayrandom.uniform(0.5, 1.2))的意思是在指定秒后调用tick()一次tick()内部再安排下一次调用这就形成一个无阻塞的随机间隔定时器。为什么不用time.sleep因为游戏主循环是单线程的如果在全局update里sleep整个窗口会卡住无法操作。UI部分用Text组件加载2D文字。position这里不是3D坐标而是屏幕归一化坐标范围大致在-1到1之间。刚开始我经常把这个和Entity.position搞混后来记了一句话凡是Text、WindowPanel、Button这类UI控件位置参数都是屏幕坐标凡是Entity位置参数都是3D坐标。这句话帮我少踩了很多坑。3.3 为什么不建议直接遍历scene.entities上面代码里有一个不够优雅的地方for obs in scene.entities会遍历场景中所有实体包括地面、玩家、UI和所有掉落物。在这个小游戏里没问题但如果实体数量上到几百每帧遍历所有实体做碰撞判断性能会直线下降。更稳妥的做法是单独维护一个障碍列表obstacles [] def update(): for obs in obstacles[:]: if obs.intersects(player).hit: score 1 destroy(obs) obstacles.remove(obs) update_display() break用obstacles[:]复制列表再遍历原因是在Python里直接遍历一个列表同时删除其中的元素会触发“list changed size during iteration”的报错。这个错误我第一版就踩到了当时球一被接住程序就崩一度怀疑是Ursina有bug其实是我基础语法不过关。4. 修改记录从“能跑”到“好玩”的五个关键调整4.1 碰撞体缺失为什么intersects永远不生效我的第一版代码里创建玩家和障碍时没有写collider。结果每次接住方块intersects都返回False我以为碰撞检测写错了折腾了很久。后来查文档才反应过来Ursina的model只是视觉模型碰撞体需要单独指定。你可以在构造时加colliderbox也可以之后动态赋予。如果觉得默认碰撞体不够准确还可以用MeshCollider(entity.model)为模型生成贴合网格的碰撞体但那会牺牲性能小游戏里用不上。4.2 玩家范围限制clamp解决飞出边界第一版没有限制挡板的活动范围玩家可以一路走到地图外然后掉落到视线的正下方从底部仰视整个游戏画面完全变形。我在地面尺寸设为20后给玩家的位置加了clamp(player.x, -9, 9)和clamp(player.z, -6, 6)。为什么不是±10因为挡板本身有宽度和长度如果它的中心到9边缘不会完全超出地面视觉和物理都更自然。clamp是Ursina自带的工具函数相当于把值卡在上下界之间比min(max(x, a), b)直观很多。4.3 手感调整速度和随机掉落间隔最早版本里所有掉落物用同一个固定速度下落游戏玩起来特别规律玩家站在场地中央就能接住大部分方块。修改成每个掉落物用自己的vy之后玩家才需要真正跑动起来。生成间隔也从固定1秒改成random.uniform(0.5, 1.2)秒打破节奏可玩性提升很大。这里我提一个参数调整经验速度值不要从0.1、0.2这种整数去想要结合每帧帧率和场景大小感受我最后定在0.12和下落速度0.03-0.06不是算出来的是试出来的。4.4 相机跟随让“3D感”真正出来默认相机是固定在一个能看到全场的高度角适合调试但不吸引人。我后来把相机挂在玩家身上camera.parent player camera.position (0, 12, -18) camera.rotation_x 20这样玩家移动时相机始终保持相对位置画面变成俯视追尾视角。这套参数我不是一次调对的一开始camera.position(0, 5, -8)挡板占了画面一半后来把y调高到12、z调到-18才得到“挡板在下部、掉落物从上方出现”的舒适构图。记得camera.parent player之后相机坐标是相对玩家的所以不要再去设他的世界坐标否则会乱套。4.5 声音与落地反馈接住方块后如果能“嘀”一声手感会显著增强。Ursina里播放音频的方式pickup_sound Audio(soundaudio/pickup.wav, autoplayFalse) ... pickup_sound.play()音频文件推荐放在项目目录的audio子目录下用相对路径引用。我最初把wav文件放在中文路径的桌面上Ursina读取失败但没有任何报错静默无声音白白排查了很久。改成项目内英文路径后一切正常。5. 高频问题排查与性能优化实战5.1 启动、黑屏和闪退问题速查表症状常见原因解决方法pip install ursina报编译错误Python版本与Panda3D不匹配换Python 3.10或3.11Ursina()提示找不到panda3d依赖没有正确安装执行pip install panda3d ursina运行后窗口黑屏显卡驱动旧、虚拟机环境、OpenGL支持差更新驱动本机运行或设置window.vsyncFalse窗口一闪而过在交互式环境里运行app.run()写在.py文件里运行音频无声路径含中文或资源不存在使用英文相对路径程序启动好慢--onefile打包方式导致解压改用--onedir5.2 碰撞检测失灵的几个隐蔽原因碰撞检测大概是被问到最多的问题。除了忘记设置collider外还有几种容易忽略的情况第一种动态修改了scale之后碰撞体没有同步。比如掉落物生成后你把scale改成原来的两倍但碰撞体还是旧尺寸。解决方法是要么在修改scale后重新给collider赋值要么一开始创建时就定好最终scale。第二种模型旋转了但碰撞体没跟着转。Ursina的box碰撞体会跟随实体旋转这没问题但如果你用自定义MeshCollider某些网格在旋转后判定会有偏差简单模型用box更省心。第三种掉落物生成位置恰好与玩家重叠导致瞬间扣分。我加了一个生成约束新生成的位置与玩家在XZ平面上的距离不小于2。如果你也想加思路是pos_x random.uniform(-8, 8) pos_z random.uniform(-5, 5) while abs(pos_x - player.x) 2 and abs(pos_z - player.z) 2: pos_x random.uniform(-8, 8) pos_z random.uniform(-5, 5)这个约束让游戏不会出现“刚出现就被接住”的bug也顺便提升了手感。5.3 性能优化对象池和实体管理的坑Ursina创建实体不是没有代价的。每个实体都要注册进场景图分配渲染数据如果每秒生成两个障碍五分钟就会产生几百个实体其中大部分已经掉出画面但没被销毁帧率自然下降。所以第一原则是及时销毁跳出屏幕就destroy被接住也destroy。第二原则是复用对象尤其当玩法需要大量同类物体时对象池能显著减少创建和销毁开销。一个极简的对象池实现如下pool [Entity(modelsphere, enabledFalse) for _ in range(30)] def get_obstacle(): for e in pool: if not e.enabled: e.enabled True return e return None用enabledFalse让实体不参与渲染和逻辑再用的时候设置位置、速度然后enabledTrue。这样比不断创建新实体要快很多。我后期把活跃掉落物数量从几十降到了20左右掉帧问题明显缓解。另外如果你不需要FPS计数器可以在创建窗口后把它关掉window Ursina() window.fps_counter.enabled False这一个操作能省一点OpenGL绘制开销对低配机器友好。6. 发布和扩展从Demo变成真正能交出去的东西6.1 用类组织代码不要让update变成大杂烩玩到后面update()里会挤满输入、碰撞、生成、UI刷新这些逻辑。我第二次重构时把每个掉落物做成一个FallingBlock类class FallingBlock(Entity): def __init__(self): super().__init__( modelrandom.choice([cube, sphere]), colorrandom.choice([color.red, color.yellow, color.magenta]), scalerandom.uniform(0.4, 0.8), position(random.uniform(-8, 8), 14, random.uniform(-5, 5)), colliderbox ) self.vy random.uniform(0.03, 0.06) def update(self): self.y - self.vy if self.y -3: destroy(self)这样主循环里一个物体一个类自带动画和生命周期。增加新的敌人类型也不用改主文件新建一个子类就好。可能有人觉得一个几百行的项目不需要面向对象但如果你还要继续加关卡、道具这个习惯能保护你未来的头发。6.2 用PyInstaller打包成独立可执行文件自己电脑上能跑和拿给朋友玩中间还差一个打包步骤。Ursina项目打包比普通脚本复杂因为需要收集Ursina、Panda3D的DLL和资源文件。我的命令行如下pyinstaller --onedir --windowed --add-data assets;assets --collect-submodules ursina --collect-all panda3d main.py注意Windows的分号Linux和macOS要改成冒号。最开始我用--onefile结果启动速度慢而且杀毒软件经常误报换--onedir后启动快很多调试也方便。如果你打包后提示缺少shader或dll大概率是--collect-all panda3d没加资源文件如果没进来检查--add-data路径是否和代码里相对路径一致。建议打包后在干净的虚拟机里跑一次确认不会因为缺少本地库崩掉。6.3 接下来还能改些什么如果你已经把这个接方块游戏跑起来了想往更深走我给几条低风险、立刻能做的扩展方向多关卡随着分数提高把生成间隔变小、掉落速度变快同时给地面换个颜色提示关卡切换。道具系统随机生成金色方块接住后进入“磁力模式”周围掉落物自动被吸附过来。实现方式是在全局update里计算每个障碍与玩家的距离小于阈值就修正其位置。鼠标控制设置mouse.lockedTrue用mouse.velocity.x控制挡板移动手感会更像FPS。自定义模型用Blender建模导出OBJ文件比如把掉落物换成低模小行星Entity(modelassets/rock.obj)就能显示。注意OBJ同目录下要有MTL材质文件和贴图。最后分享一点个人体会。我踩过许多坑之后最大的收获不是学会了某个API而是彻底理解了“3D游戏的所有视觉问题最后都能归结为坐标和碰撞问题”。遇到奇怪现象先检查坐标是否在预期范围、再检查碰撞体是否同步大多数问题都能迎刃而解。Ursina的报错信息通常很直接它会告诉你是哪个实体出了问题所以不要怕它。Python用户想在3D游戏上快速看到成果这条路值得走。
延伸阅读

更多相关文章

2026/10/13 2:29:50

Vibe Coding工程化落地:从提示词到稳定运行的完整路径

Vibe Coding这两年从一个口号变成了实打实的开发方式,我身边越来越多同事开始用自然语言直接驱动代码生成,不再一个个函数手敲。但这个概念被炒得太玄了,好像只要会打字就能当架构师。我在自己的项目和团队里实践了大半年,踩了不少…

2026/10/13 2:29:50

用Python和Ursina开发3D小游戏:从Demo到完整可玩

说真的,Python写3D游戏,很多人第一反应是“不靠谱”。帧率、性能、引擎成熟度……这些话听多了,我反而觉得得看场景。如果你像我一样,只是想把脑袋里一个小想法变成能跑、能玩、能拿去演示的东西,Python Ursina的组合…

2026/10/13 3:54:55

UE5视差材质原理与实操:POM节点、参数调优与避坑指南

简介:这套UE5视差材质资源包面向虚幻引擎开发者和视觉设计师,系统展示基于高度图的视差贴图技术,用于低成本营造物体表面深度感,提升场景真实度,可广泛应用于游戏开发、建筑可视化与电影制作。压缩包共478个文件&#…

2026/10/13 3:54:55

mfc140.dll丢失怎么修复?官方免费方法+深度排查思路

“由于找不到mfc140.dll,无法继续执行代码。重新安装程序可能会修复此问题。”如果你最近被这个弹窗卡住过,那多半是某个软件或游戏客户端突然打不开了。每次遇到这种弹窗,第一反应都是先上网搜一下“mfc140.dll丢失怎么解决”,结…

2026/10/13 3:54:55

软件配置管理实战:从版本控制到变更治理的完整解析

软件配置管理(SCM)这个东西,刚入行的开发可能觉得它就是“用Git管代码”,干了两三年以后,会发现事情远没有这么简单。我在带团队和做技术管理的这些年里,见过太多次因为配置管理没做好而引发的线上事故、团…

2026/10/13 3:54:55

Linux磁盘管理全攻略:分区、挂载、LVM、配额与排障实践

做运维这些年,我越来越发现一个事实:很多看似诡异的问题,最后都能在磁盘管理上找到病灶。要么是分区规划不合理导致空间浪费,要么是挂载配置写错开机直接进不了系统,要么是日志把根分区撑爆才发现当初没做配额。磁盘管…

2026/10/13 3:49:55

进程通信全解析:从管道到Socket的选型与实践

写进程通信这块内容,我心里一直有个念头:教科书把管道、消息队列、共享内存、信号量、Socket一个个平铺开来,讲完定义讲API,讲完API讲例子,但真到自己写多进程程序时,最常遇到的困惑是“我到底该用哪个”。…

2026/10/11 0:02:13

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/12 12:01:26

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/12 12:54:07

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/13 0:04:41

基于Triton部署YOLO目标检测:从训练脚本到线上服务

简介:这份资源面向希望将目标检测模型落地到生产环境的深度学习开发者与算法工程师,聚焦于用 Triton Inference Server 部署 YOLOv9 的完整实战方案,解决从训练权重到线上推理服务之间的工程化难题。压缩包共 16 个文件,约 886KB&…

2026/10/13 0:04:41

手把手教你云端部署Qwen-Image-2.1:从GPU选型到在线文生图服务

Qwen-Image-2.1 这个开源的图像生成模型,最近在内容创作和 AI 应用圈子里热度相当高。它属于通义 Qwen 系列里的视觉生成分支,让我眼前一亮的是它对中文长文本的理解能力——以前用那些老方案生成带中文文字的图,十个里九个是“乱码印刷体”&…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑