ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

用Python复刻我的世界小游戏:体素引擎与区块存储实战

2026/9/10 13:23:17 拓冰建站 浏览量
用Python复刻我的世界小游戏:体素引擎与区块存储实战 简介这是一套基于Python和Pygame库实现的‘我的世界’风格二维沙盒小游戏源码面向已经掌握Python基础语法、希望真正进入游戏开发领域的初学者。项目借助窗口创建、事件监听、方块绘制、碰撞检测与帧速率控制等机制完整展示像素化沙盒游戏的核心交互流程其中还涉及面向对象建模、简单音效处理与数据序列化思路非常适合作为从语法学习过渡到项目实战的桥接案例。压缩包体积仅有15KB共4个文件分别对应主程序main.py、说明文档README.md、游戏纹理texture.png以及开源许可证LICENSE文件数量精炼但代码组织清晰方便逐行阅读与上机调试。当前已有7083人学习或下载热度侧面反映了该资源的实用性。跟随源码梳理读者既能理解Pygame初始化、主循环、事件响应、碰撞判定和动画刷新等常见游戏开发环节也能借鉴其简洁的工程结构快速搭建自己的小型游戏原型为后续扩展存档、音效与更多玩法打下扎实基础。1. 用 Python 复刻“我的世界”小游戏先把体素引擎当成数据问题搜索“python我的世界小游戏源代码”大多数人想拿到的是一个能跑起来、能走能挖能放方块的方块世界而不是一个完整的 MC 克隆。这个标题真正值钱的地方在于复刻《我的世界》最难的不是渲染而是把体素世界组织成结构化数据——方块怎么存、区块怎么切、视线里的目标方块怎么选中。这几个问题恰好又都是 Python 里最值得练的坐标与数据结构题。所以这篇按一条最容易验收的路子来写Python 3 环境加 ursina 引擎用几百行代码搭出第一人称体素小游戏的骨架同时把存储、拾取、存档三个最容易写崩的点埋进去。纯色贴图不依赖外部美术资源笔记本上就能跑。新手可以照着一步步跑通写过几年 Python 的读者也可以在这个骨架上对比一下“字典存方块”“区块 bytearray”“延迟落盘”这些方案的边界。2. 区块与方块类型Python“我的世界”小游戏源代码的世界存储层2.1 用字典还是数组存方块先定 block_type 表所有体素引擎的第一步都是回答同一个问题给定一个整数坐标 (x, y, z)这个位置是什么。最常见的答案是维护一张(x, y, z) - int的映射整数在这里不是 RGB 颜色而是方块类型编号0 是空气1 是草方块2 是泥土3 是石头。这段代码就是把这种映射落成最小实现# 方块类型表编号 - 名称、颜色、是否为实体 BLOCK_TYPES { 0: {name: air, color: None, solid: False}, 1: {name: grass, color: (0.3, 0.8, 0.2), solid: True}, 2: {name: dirt, color: (0.5, 0.3, 0.1), solid: True}, 3: {name: stone, color: (0.5, 0.5, 0.5), solid: True}, } class World: def __init__(self, chunk_size16, world_height64): self.blocks {} # (x, y, z) - block_id self.chunk_size chunk_size self.world_height world_height def set_block(self, x, y, z, block_id): if block_id 0: # 挖掉方块 从字典中删除而不是写入 0 self.blocks.pop((x, y, z), None) else: self.blocks[(x, y, z)] block_id def get_block(self, x, y, z): # 查不到就是空气 return self.blocks.get((x, y, z), 0)逻辑说明set_block里对空气单独处理是因为方块世界天然稀疏地面以上几百格都是空气。如果挖掉一个方块还继续把它留在字典里占位一次探索下来字典里会堆积大量无意义的空键。用pop删除才是真正的稀疏存储。参数说明world_height是这个世界允许的最高 y 值后面做区块数组和存档边界检查都会用到chunk_size是区块边长先取 16。这个值是体素游戏里最常见的区块尺寸下一节说明为什么不是 8 也不是 32。2.2 区块 16 × 16 这个数字从哪里来“区块”是《我的世界》世界观里的经典概念把世界按水平方向切成若干方块组每个组作为一个独立的加载、保存和裁剪单位。这个标题下的 Python 小游戏也继承了这个设计。区块尺寸的常见选择如下区块边长单区块方块数按 64 层高对渲染裁剪的帮助实际体验84096太碎片化走几步就要重新检查外围区块加载频繁存档文件零碎16163845×5 的区块阵列就能覆盖常见视野体素小游戏最常用的折中值3265536边缘过大可见性裁剪不精细适合做“区域”而不是最小单位确定边长后就要处理世界坐标到区块坐标的换算。这个环节在 Python 里有一个特别容易被忽视的坑负数的//和%是向下取整而不是向零取整。def block_key(x, y, z): # 世界坐标转区块坐标 区块内局部坐标 cx, cz x // 16, z // 16 lx, lz x % 16, z % 16 return cx, cz, lx, y, lz # 验证世界坐标 -1 会落到区块 -1 的局部坐标 15 assert block_key(-1, 5, -1) (-1, -1, 15, 5, 15) # 再由区块坐标算回世界坐标 wx -1 * 16 15 # 结果是 -1逻辑说明x // 16和x % 16是配套的一对操作cx * 16 lx永远还原成原坐标。如果用int(x / 16)代替//-1 会被截断成 0局部坐标却是 15整个世界的 x 轴左侧就会错位一格。参数说明block_key返回的cy没有参与计算因为小游戏世界默认永不改变 y 方向上的区块边界整个世界高度就固定为world_height。如果你要做地下世界或天空岛再把 y 也纳入区块坐标原理完全相同。2.3 区块内用 bytearray比字典更“抠”的实现字典解决的是“整个世界稀疏存”的问题但如果一个区块内部已经被填满还用字典逐格存储内存开销就明显了。Python 的 int 是对象一个格子放进字典至少占用几十字节而 16×16×64 的区块总共 16384 个格子完全可以退化成连续数组。class Chunk: def __init__(self, cx, cz, height64): self.cx, self.cz cx, cz self.height height # 一维 bytearray每个字节存一个方块 id self.blocks bytearray(16 * 16 * height) def index(self, x, y, z): # 把 x 放最低位y 放最高位 return x z * 16 y * 256 def get(self, x, y, z): return self.blocks[self.index(x, y, z)] def set(self, x, y, z, block_id): self.blocks[self.index(x, y, z)] block_id逻辑说明bytearray每个元素是 0 到 255 的无符号整数一个格子的存储代价是 1 字节。index的展开顺序是x z * 16 y * 16 * 16也就是内存里 x 方向连续排列。生成地形时如果按 x 内层循环写内存访问顺序也就连续了。参数说明单区块 16384 字节5×5 的区块阵列才 400 KB 左右这就是为什么 bytearray 方案能让“我的世界小游戏”直接在普通笔记本上跑起来。但这套方案只适合方块 id 不超过 255 的场景如果需要 256 种以上方块就要换成array(H)或 numpy 的uint16数组。2.4 世界生成从“高度图”开始而不是随机撒方块初学者写体素世界很容易直接在三维坐标里 random结果是满地斑点不像地形。常见做法是先生成一张二维高度图再根据高度值逐列填充方块。下面的代码就是在 16×16 区块里用多个正弦波叠加模拟缓慢起伏的地形from math import sin def generate_chunk(cx, cz, chunk_size16): chunk Chunk(cx, cz) for lx in range(chunk_size): for lz in range(chunk_size): wx cx * chunk_size lx wz cz * chunk_size lz # 多层正弦叠加大周期控制山丘小周期控制起伏 height int(6 3 * sin(wx * 0.08) 2 * sin(wz * 0.08)) height max(1, height) for y in range(height): block_id 1 if y height - 1 else 2 # 顶部草方块下面泥土 chunk.set(lx, y, lz, block_id) return chunk逻辑说明height是这一列地表方块的 y 值。草地只放在最高一层下面全是泥土这是《我的世界》地表结构的简化版。正弦波的系数 0.08 控制地形起伏频率越大山丘越密集。参数说明最外层int(...)之后要接max(1, height)防止正弦值叠加后出现 0 层的地形空洞。如果你觉得地形太单调再加不同频率和振幅的正弦项如果觉得太规则把其中一项换成随机数种子就能得到更自然的噪声地形。3. 用 ursina 搭第一人称“我的世界”小游戏从最小原型开始3.1 为什么选 ursina 而不是 pygame想做 3D 第一人称体素小游戏行内最常见的可靠方案是 ursina。它构建在 Panda3D 之上自带第一人称控制器、实体射线检测和立方体模型正好覆盖“我的世界”类小游戏的三件套移动、挖掘、放置。如果坚持用 pygame 从零写投影和碰撞工作量会落在矩阵变换上而不是体素逻辑本身。方案第一人称控制器方块拾取立方体渲染适合场景pygame 自写 3D自己写投影和碰撞自己写射线求交自己写图元2D 或等距视角的简化版ursina内置 FirstPersonController内置 raycast内置 cube 模型3D 第一人称体素小游戏Panda3D 裸写自己拼装自己拼装内置追求底层控制的中大型项目3.2 最小项目初始化venv 加 ursina先把环境准备好。下面这段命令在不同系统上只有激活语句不同python -m venv .venv # Windows: .venv\Scripts\activate # macOS / Linux: source .venv/bin/activate pip install ursina参数说明用 venv 而不是直接往全局环境里装是为了不污染系统 Python。ursina 依赖 panda3d安装包体积不小建议网络条件一般时用国内镜像源pip install ursina -i https://pypi.tuna.tsinghua.edu.cn/simple。3.3 第一人称控制器的 5 个必调参数FirstPersonController是 ursina 提供的现成控制器但默认参数手感偏“快”复刻方块世界时需要调整。最影响手感的是下面这张表里的参数参数默认值我的建议值作用说明speed1410地面移动速度原版 MC 步行约 4.3 米/秒小游戏里可适当加快jump_height11.4跳跃高度低于 1 会跳不上 1 格高的台阶jump_duration0.40.3跳跃滞空时间值越大跳得越“飘”gravity11.5重力系数越大下落越快mouse_sensitivityVec2(40, 40)Vec2(25, 25)鼠标灵敏度默认太快容易转晕最小可玩代码看起来像这样from ursina import * app Ursina() player FirstPersonController( position(0, 10, 0), speed10, jump_height1.4, jump_duration0.3, gravity1.5, mouse_sensitivityVec2(25, 25), ) app.run()逻辑说明position(0, 10, 0)是把玩家生成在高度 10避免出生点卡进地面。Ursina()必须先创建再实例化任何实体。app.run()进入主循环之后所有键盘鼠标事件都由 ursina 分发。参数说明jump_height与“能否跳上一格方块”直接相关。一块方块的边长是 1玩家脚底要越过 top 面至少 1 个游戏单位所以 1 是极限值留出 0.4 的余量更稳妥。3.4 把方块画出来Button 既是模型又是触发器在 ursina 里可点击的方块通常用Button而不是普通Entity因为Button默认带colliderbox并且能响应鼠标点击。这意味着方块既能被玩家踩在脚下又能被鼠标选中省去手写碰撞体。class Block(Button): def __init__(self, position(0, 0, 0), block_type1): r, g, b BLOCK_TYPES[block_type][color] super().__init__( parentscene, modelcube, texturewhite_cube, # 纯白贴图靠 color 染色 colorcolor.rgb(r * 255, g * 255, b * 255), positionposition, colliderbox, block_typeblock_type, ) def build_world_from_chunks(world): for (cx, cz), chunk in world.chunks.items(): origin_x cx * world.chunk_size origin_z cz * world.chunk_size for x in range(16): for y in range(chunk.height): for z in range(16): block_id chunk.get(x, y, z) if block_id ! 0: Block(position(origin_x x, y, origin_z z), block_typeblock_id)逻辑说明texturewhite_cube是 ursina 内置的白色立方体贴图配合color染色一个方块类型只需要改颜色值不用准备多张贴图。三层循环会遍历区块里的所有格子空气直接跳过不生成实体。参数说明这段代码最耗时的部分是循环本身。一个区块最多 16384 个格子5×5 区块就是 409600 次循环。如果初始化明显卡顿最简单的优化是只画裸露在外层的方块也就是那些至少有一个相邻面是空气的方块。我在小项目里常用的做法是x 或 z 等于 0 或 15以及 y 等于 height-1 的格子才生成Block其余默认玩家看不到。3.5 锚点与坐标系脚陷进地面半格的问题实体默认的锚点是中心而方块塌落下来时玩家站在“中心高度 半格”的位置视觉上脚会陷进去。解决办法是在Block里加origin_y0.5把锚点从中心移到方块底部class Block(Button): def __init__(self, position(0, 0, 0), block_type1): ... super().__init__( ..., origin_y0.5, # 锚点放在底部玩家站在 y 整数位 )逻辑说明ursina 的 y 轴向上方块的几何中心在y 0.5。不设origin_y玩家站在方块顶上时脚底会被碰撞体抬到y 1行走时每一步都像踩在空气上。origin_y0.5之后方块的碰撞底面和视觉底面重合在y处。参数说明如果你后续给方块加缩放动画要注意origin_y是按模型原始尺寸计算的缩放值会叠加在锚点上。想要“方块从地里长出来”的效果把锚点放到底部再配合scale_y动画是最省事的做法。4. 目标方块拾取放方块和挖方块怎么选中视线里的那一格4.1 用 raycast 做第一版拾取放置和破坏的“瞄准”逻辑本质是求一条视线射线与最近方块的交点。ursina 的raycast返回碰撞实体和碰撞点法线这正是一个体素小游戏需要的全部信息def get_target_block(): hit_info raycast( camera.world_position, camera.forward, distance8, # 拾取距离经典 FPS 数值 ignore[player], # 别打到自己 ) return hit_info def input(key): if key left mouse down: hit get_target_block() if hit.hit: pos hit.entity.position world.set_block(int(pos.x), int(pos.y), int(pos.z), 0) destroy(hit.entity) # 从场景中移除实体 elif key right mouse down: hit get_target_block() if hit.hit: # 放置位置 被击中方块 表面法线方向 px int(hit.world_point.x hit.normal.x * 0.1) py int(hit.world_point.y hit.normal.y * 0.1) pz int(hit.world_point.z hit.normal.z * 0.1) if world.get_block(px, py, pz) 0: world.set_block(px, py, pz, 1) Block(position(px, py, pz), block_type1)逻辑说明破坏时直接取hit.entity.position因为被击中的方块实体坐标就是它的格子坐标。放置时不能拿camera.forward加在方块坐标上因为视线方向是连续向量加出来大概率落在空中或方块内部正确做法是取碰撞点加上“表面法线×极小偏移”再进行整数化。hit.normal是碰撞面的朝向乘 0.1 是为了让碰撞点从表面外部一点开始取整。参数说明distance8是拾取距离数值越大能挖到越远的方块。生存玩法想要“贴身挖掘”的手感可以改成 3想要测试世界生成12 也不是不行。ignore[player]不能少否则从玩家内部打出的射线会先撞上玩家自己的碰撞体。4.2 射线命中的三种常见实现如果不想依赖 ursina 的高层 raycast或者想要更精确地按“格子”拾取可以自己实现体素射线步进。常见方案对比方案精度性能实现难度适合场景ursina 自带 raycast基于实体包围盒快只扫描场景实体低小游戏首选均匀步进采样有漏检风险O(距离/步长)低理解原理、教学演示DDA 网格步进精确到格O(穿过的格子数)中追求手感与通用性均匀步进采样是所有自写方案里最容易看懂的def raycast_voxel(origin, direction, max_distance8.0): step_size 0.05 # 步长越小越不容易漏但开销越大 steps int(max_distance / step_size) x, y, z origin dx, dy, dz direction for _ in range(steps): x dx * step_size y dy * step_size z dz * step_size block_id world.get_block(int(x), int(y), int(z)) if block_id ! 0: return int(x), int(y), int(z) return None逻辑说明每一步把射线位置向前推进 0.05 个游戏单位然后查询该位置所在的方块。因为方块边长是 1理论上步长小于 0.5 就不会穿模取 0.05 是图个省心。参数说明步长越小精度越高但循环次数线性增加。max_distance8时循环 160 次性能完全可接受如果把距离改成 100就要考虑 DDA 或把步长增大到 0.1。均匀步进的另一个弱点是斜向射线在格子角落附近可能连续两个采样点跨越格子边界这也就是表中“有漏检风险”的意思。4.3 防止误操作冷却时间与边界限制“我的世界”小游戏里最常见的误操作是按住鼠标左键连挖一瞬间把面前的好几个方块全拆了。因为主循环每帧都会调用input鼠标按下的那一帧和按住不放的后续帧都会触发。from ursina import time last_use_time 0 def try_use_block(): global last_use_time if time.time() - last_use_time 0.2: # 200ms 冷却 return last_use_time time.time() # 上面的放置与破坏逻辑放这里逻辑说明time.time()是 ursina 自带的秒级计时器。每次操作后记录当前时间下一次操作距离上次不足 0.2 秒就直接忽略。这个机制同时作用于挖掘和放置避免误触。参数说明0.2 秒对应 5 次/秒的操作频率比较接近手动点按的极限。想做“连锁挖矿”就把冷却缩短到 0.05 秒想做“钝器手感”就调到 0.3 秒以上。冷却时间不要小于主循环的帧间隔否则等于没设。边界限制方面我通常会在世界里记录一个水平半径radius放置时把 x、z 限制在-radius * chunk_size到radius * chunk_size之间。如果不加这一层限制玩家可以走到未生成区块放一个孤立方块存档时再一带一整套边界逻辑很麻烦。5. 让源代码更像成品方块存档与区块预生成调度5.1 存档选 JSON 还是 SQLite做到能挖能放之后下一步是退出程序再进来世界还在。小体量项目里最常用的三种持久化方案各有边界存档方式回写速度数据结构友好度适合场景JSON 全量 dump数据量大后保存卡顿高json.dump一行搞定演示项目、单文件存档按区块分文件只写脏区块快中要处理目录结构中等大小世界SQLite 单库随机读写快中需要建表想顺手练 sqlite3 的场景JSON 全量 dump 是最容易跑通的做法而且世界数据本身就是字典序列化非常直观import json def save_world(world, pathworld.json): blocks [] for (x, y, z), block_id in world.blocks.items(): blocks.append([x, y, z, block_id]) with open(path, w, encodingutf-8) as f: json.dump({chunk_size: world.chunk_size, world_height: world.world_height, blocks: blocks}, f) def load_world(world, pathworld.json): with open(path, r, encodingutf-8) as f: data json.load(f) world.chunk_size data[chunk_size] world.world_height data[world_height] world.blocks {(x, y, z): block_id for x, y, z, block_id in data[blocks]}逻辑说明字典的 key 是元组元组不能直接作为 JSON key所以先转成[x, y, z, block_id]的列表再存。读档时再用列表推导式还原成字典。chunk_size和world_height一起存是为了防止读档时用到不同的世界边界。参数说明JSON 方案在方块数达到几十万级时会明显变慢。如果你预计存档里方块超过 50 万趁早换 sqlite3表结构就用一列 rowid 加x, y, z, block_id四列再给(x, y, z)建唯一索引即可。5.2 按玩家坐标做区块预生成调度“我的世界”小游戏碰到的最常见性能瓶颈不是方块太多而是出生点一次性把所有区块全部生成。正规做法是以玩家目前所在区块为中心只生成视距内的方块离开视距就卸载。def update(self): cx int(player.x) // world.chunk_size cz int(player.z) // world.chunk_size # 以玩家为中心按视距预生成 for ox in range(-view_radius, view_radius 1): for oz in range(-view_radius, view_radius 1): key (cx ox, cz oz) if key not in self.chunks: chunk generate_chunk(cx ox, cz oz) self.chunks[key] chunk # 只有新生成的区块需要渲染成实体 render_chunk_entities(chunk) # 超出视距的区块卸载并释放实体 for key in list(self.chunks.keys()): if abs(key[0] - cx) view_radius or abs(key[1] - cz) view_radius: unload_chunk(self.chunks.pop(key))逻辑说明update每个主循环帧都会被调用。第一段双层循环负责补上新增区块第二段用list(...)快照遍历是因为边遍历边pop会跳过元素。view_radius表示玩家四周保留几圈区块。参数说明view_radius2时保留 5×5 共 25 个区块约 40 万个格子中等配置的笔记本可以流畅跑。调成 4 就是 81 个区块实体数量会明显上升此时必须配合“只生成裸露方块”的优化。5.3 用脏区块计数控制落盘频率存档最怕在游戏进行中频繁全量写盘。我一般会为每个区块维护一个修改计数达到阈值才真正落盘chunk_dirty {} def set_block_with_dirty(x, y, z, block_id): world.set_block(x, y, z, block_id) key (x // world.chunk_size, z // world.chunk_size) chunk_dirty[key] chunk_dirty.get(key, 0) 1 if chunk_dirty[key] 64: save_chunk_to_json(key) chunk_dirty[key] 0逻辑说明set_block被挖矿和放置两条逻辑共用所以把脏计数放在这里最省事。阈值 64 意味着一个区块内累计 64 次方块变更就写一次盘。玩家退出时再做一次全量保存收尾。参数说明64 这个数字基于“每帧最多改几个方块”的经验值。如果想更稳可以在app.step的末尾检测当前帧距上次写盘是否超过 3 秒时间与次数两个条件用“或”的关系触发落盘。要注意的是chunk_dirty计数只有在方块真正改变时才递增连续点击同一个位置不会触发无意义的写盘因为set_block里相同方块 id 的赋值不会更新字典。本文还有配套的精品资源点击获取