资讯详情

Python pygame塔防游戏源码解析:主循环、碰撞与二次开发

📅 2026/10/30 10:10:49 | 华诺云谱 👁 阅读
Python pygame塔防游戏源码解析:主循环、碰撞与二次开发
简介这是一份基于 Python 编写的植物大战僵尸复刻源码包面向有一定 Python 基础、希望借助经典游戏案例练习图形界面与游戏循环逻辑的开发者与学习者。压缩包共 10 个文件、约 42KB以 8 个 png 图片素材草坪地图、豌豆射手、向日葵、僵尸、子弹等角色贴图、1 个 py 主程序脚本和 1 个 md 说明文档组成素材与代码一并提供下载后无需另行搜集美术资源即可运行调试。已有 1791 人学习下载说明它在同类练手项目中具备一定参考价值。读者可从主脚本中了解角色与子弹的生成、移动与碰撞判定草坪网格坐标与图片贴图的对应方式以及关卡地图的拼接思路README 文档则给出运行环境与使用说明便于快速上手改造比如替换贴图、调整植物属性或新增僵尸波次适合作为课程设计、期末项目与个人练习的起点。1. 从一份 Python 塔防源码说起它到底能干什么用 Python 写植物大战僵尸风格的塔防玩法几乎是每个学完 pygame 基础的人都会动一次的念头画格子、种植物、僵尸排队走过来、豌豆飞出去把僵尸打倒。这份源码就是把这件事完整做完的版本不是画个草坪就收工的练习阳光经济、卡槽冷却、波次生成、子弹命中、僵尸啃食、胜负判定都在里面。它适合两类人一类是刚摸完 pygame 教程、想找个完整项目把事件循环和精灵系统串起来另一类是想拿它当课程设计底稿、往里塞自己的美术和关卡数据。纯 Python pygame不依赖引擎读懂一行就能改一行这是它最值钱的地方。2. 工程结构与环境搭建pygame 塔防的目录骨架与启动链路拿到一份没读过的 Python 游戏源码我的习惯是先看目录再看入口最后才去啃玩法逻辑。目录决定了作者有没有分层意识入口决定了依赖能不能装上玩法逻辑反而是最容易读的部分。这一章先把这份源码的骨架摊开再把它跑起来最后讲一层最容易被忽略、也最容易出问题的东西——资源路径和坐标系约定。2.1 目录结构谁管状态、谁管实体、谁管资源源码的目录大致长这样不同版本细节会有出入但分层思路是一致的td-game/ ├── main.py # 唯一入口负责初始化和退出 ├── config.py # 全局常量分辨率、帧率、网格尺寸、路径 ├── core/ │ ├── game.py # 场景栈 主循环 │ ├── scene.py # 场景基类定义 handle_event/update/draw 三件套 │ └── resource.py # 图片、音效、字体的加载与缓存 ├── entities/ │ ├── plant.py # 植物基类 向日葵、豌豆射手、坚果 │ ├── zombie.py # 僵尸基类与状态机 │ ├── bullet.py # 子弹含减速等附加效果 │ └── sunlight.py # 阳光掉落物 ├── scenes/ │ ├── menu.py # 开始菜单 │ └── battle.py # 战斗主场景玩法都在这 ├── assets/ │ ├── images/ │ ├── sounds/ │ └── fonts/ └── data/ └── level_1.json # 波次配置core这一层是引擎性质的东西跟具体玩法无关你换一套美术和规则它照样用。entities是游戏对象每个类都继承pygame.sprite.Sprite自带image和rect两个属性这样才能塞进pygame.sprite.Group里批量更新和碰撞。scenes负责把对象组装起来菜单和战斗是两个独立场景。data放数值配置这一层做得好不好直接决定你后面改平衡是改表格还是满代码库找数字。我一般会额外在config.py里放一套全局常量所有模块从这里取不允许硬编码数字# config.py import os BASE_DIR os.path.dirname(os.path.abspath(__file__)) ASSETS_DIR os.path.join(BASE_DIR, assets) SCREEN_W, SCREEN_H 1000, 600 FPS 60 GRID_COLS, GRID_ROWS 9, 5 CELL_W, CELL_H 80, 100 GRID_ORIGIN (250, 80) # 草坪左上角在屏幕中的坐标 START_SUN 50 SUN_INTERVAL 6.0 # 自然掉阳光的间隔秒 SUN_VALUE 25BASE_DIR用abspath(__file__)推出来而不是os.getcwd()这是血泪经验——用当前工作目录拼路径换个地方启动就报FileNotFoundError。后面 2.3 节还会细说。2.2 依赖安装与第一次启动这份源码只依赖 pygame没有其他第三方库。建议用虚拟环境避免和你机器上其他项目的 pygame 版本打架# 创建并激活虚拟环境 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate # 安装依赖版本号以源码 requirements.txt 为准 pip install pygame2.5.2 # 启动 python main.py如果没有requirements.txt装最新版通常也能跑但 pygame 2.x 和 1.9.x 之间 API 差异不小尤其是Surface.blit的special_flags和 mixer 的初始化参数跑到一半报TypeError多半是版本不对先pip show pygame确认一下。入口文件通常很薄只做三件事初始化、进主循环、保证退出时资源释放# main.py import sys import pygame from core.game import Game def main(): pygame.init() pygame.display.set_caption(Tower Defense Demo) game Game() try: game.run() finally: # 无论正常退出还是异常都保证 pygame 被正确关闭 pygame.quit() return 0 if __name__ __main__: sys.exit(main())这里try/finally不是多余的。pygame 在部分平台上如果没走pygame.quit()窗口关闭后进程会残留调试的时候反复启动会积累一堆僵尸进程任务管理器里看着很乱。sys.exit(main())把返回值交给操作系统方便以后接 CI 时判断退出码。2.3 资源路径与坐标系最容易翻车的一层塔防游戏的坐标换算比一般小游戏复杂因为存在两套坐标屏幕像素坐标和网格坐标。源码里一般会提供两个互转函数位置放在scenes/battle.py或单独的core/grid.py# core/grid.py import pygame from config import GRID_COLS, GRID_ROWS, CELL_W, CELL_H, GRID_ORIGIN def cell_to_rect(col: int, row: int) - pygame.Rect: 网格坐标 - 屏幕矩形用于种植和绘制 x GRID_ORIGIN[0] col * CELL_W y GRID_ORIGIN[1] row * CELL_H return pygame.Rect(x, y, CELL_W, CELL_H) def pos_to_cell(pos): 鼠标屏幕坐标 - 网格坐标越界返回 None x, y pos col (x - GRID_ORIGIN[0]) // CELL_W row (y - GRID_ORIGIN[1]) // CELL_H if 0 col GRID_COLS and 0 row GRID_ROWS: return int(col), int(row) return Nonecell_to_rect负责正向计算种植时用它的返回值判断格子是否被占。pos_to_cell负责反查鼠标点击时调用。这里有两个细节一是//对负数向下取整鼠标移到草坪左侧时会算出-1必须用范围判断兜住二是返回值里的int()不能省-0.5 // 80在某些值上会得到-1.0这种浮点拿去当字典 key 会出现「明明点的是这一格却种不上」的玄学现象。资源加载建议统一收口到core/resource.py做一层缓存避免同一张图被反复load# core/resource.py import os import pygame from config import ASSETS_DIR _cache {} def load_image(name: str) - pygame.Surface: if name not in _cache: path os.path.join(ASSETS_DIR, images, name) _cache[name] pygame.image.load(path).convert_alpha() return _cache[name]convert_alpha()必须放在display.set_mode()之后调用否则会抛pygame.error: cannot convert without video mode。这一条在 5.1 节还会作为坑点单独讲。3. 主循环拆解从 tick 到 flip 的一帧完整链路游戏卡不卡、手感对不对、僵尸会不会穿模八成问题都出在主循环这一百来行代码里。很多人读源码喜欢从entities开始结果读了一堆类的属性却不知道它们什么时候被调用。我的建议是反过来先看一帧里发生了什么再回头去看每个类的update在什么时候被谁触发。3.1 主循环骨架与 dt 的意义# core/game.py import pygame from config import SCREEN_W, SCREEN_H, FPS from scenes.menu import MenuScene class Game: def __init__(self): self.screen pygame.display.set_mode((SCREEN_W, SCREEN_H)) self.clock pygame.time.Clock() self.scene_stack [MenuScene(self)] self.running True property def scene(self): return self.scene_stack[-1] def run(self): while self.running: # tick 返回距上一帧的毫秒数除 1000 转成秒 dt self.clock.tick(FPS) / 1000.0 # 关键切窗口回来 dt 可能高达几秒必须钳制 dt min(dt, 0.05) for event in pygame.event.get(): if event.type pygame.QUIT: self.running False else: self.scene.handle_event(event) self.scene.update(dt) self.scene.draw(self.screen) pygame.display.flip()clock.tick(FPS)一行做两件事把循环速度限制在 60 帧同时返回上一帧耗时。用它算dt的好处是所有运动都按「像素每秒」写不受机器性能影响。僵尸速度写成20 * dt那么在 30 帧的机器和 144 帧的机器上走过一格草坪花的时间是一样的。min(dt, 0.05)这行是后悔药。拖动窗口、系统弹通知、切到后台再切回来tick可能返回 2000 毫秒此时dt 2.0僵尸在一帧之内瞬移两格直接越过你的坚果墙。钳到 50 毫秒最坏情况也就多走一小步玩家看不出来。3.2 场景栈菜单、战斗、暂停怎么组织用栈管场景比用if state menu这种字符串判断要干净得多。核心就三个方法def push(self, scene): 进入新场景比如菜单 - 战斗 self.scene_stack.append(scene) def pop(self): 返回上一场景栈底永远保留主菜单 if len(self.scene_stack) 1: self.scene_stack.pop() def replace(self, scene): 替换当前场景用于重开一局 self.scene_stack[-1] scene栈的好处是暂停特别自然按 ESC 时push(PauseScene)暂停界面盖在战斗场景上面update只跑暂停场景的战斗场景的计时器全部冻住不需要在每个类里加if paused: return。按继续时pop()一下战斗从冻结处接着跑。事件分发也跟着栈顶走。Game.run里只把事件交给self.scene也就是栈顶那个底下的场景收不到键盘事件。如果你需要暂停界面半透明显示后面的战斗画面就在PauseScene.draw里先让下一层场景画一遍再叠一层半透明遮罩这部分源码里一般写在scenes/pause.py。3.3 更新顺序与碰撞结算为什么不能边更新边打BattleScene.update里的调用顺序是整份源码最值得抄的部分def update(self, dt): # 1. 先更新各自状态不做任何跨对象判定 self.sun_mgr.update(dt) self.plants.update(dt) self.bullets.update(dt) self.zombies.update(dt) self.wave_mgr.update(dt) # 2. 所有对象都移动到位之后再统一结算碰撞 self.resolve_bullet_hits() self.resolve_zombie_eat() # 3. 最后做清场和胜负判断 self.cleanup_dead() self.check_game_over() def resolve_bullet_hits(self): # dokill1True 表示子弹命中即销毁dokill2False 表示僵尸由血量逻辑处理 hits pygame.sprite.groupcollide(self.bullets, self.zombies, True, False) for bullet, zombies in hits.items(): for z in zombies: z.take_damage(bullet.damage) if bullet.slow: z.apply_slow(bullet.slow, duration3.0)groupcollide(group1, group2, dokill1, dokill2)返回一个字典key 是第一个组里的精灵value 是与之碰撞的第二个组精灵列表。这里子弹设成命中即删僵尸不删——因为僵尸有血量可能挨好几发删不删由take_damage里的血量判定决定。顺序为什么重要如果把碰撞结算插在zombies.update前面会出现这样的情况子弹在这一帧移动到僵尸身上但僵尸还没移动判定用的是旧位置等僵尸移动完可能已经跑到子弹后面去了视觉上就是「子弹从僵尸身体里穿过去没打中」。把所有位移做完再统一判定这类问题一次性消掉。cleanup_dead()里遍历各组把hp 0或者已经出屏的精灵kill()掉。kill()只是把它从所有组里移除对象本身还在如果这个僵尸持有对其他对象的引用比如正在啃的植物要在kill之前把引用置空否则植物那边会一直以为有人在啃它。4. 玩法内核实战阳光经济、格子种植与僵尸波次前面两章讲的是骨架这一章才是肉。塔防游戏的乐趣全在数值和节奏上而数值和节奏在代码里体现为三个东西阳光的产出速率、种植的冷却时间、僵尸波次的密度。这三个数字调好了游戏就有味道调不好要么前三十秒无事可做要么第三波直接被推平。4.1 阳光定时掉落、点击拾取与上限# entities/sunlight.py import random import pygame from config import GRID_COLS, CELL_W, GRID_ORIGIN, SUN_INTERVAL, SUN_VALUE class SunManager: def __init__(self): self.timer 0.0 self.sun 50 # 开局阳光 self.drops pygame.sprite.Group() def update(self, dt, sun_valueSUN_VALUE, intervalSUN_INTERVAL): self.timer dt # 用 while 而不是 if防止某一帧 dt 较大时漏掉一次产出 while self.timer interval: self.timer - interval self.spawn_sky_sun(sun_value) for s in list(self.drops): s.update(dt) if s.expired(): s.kill() def spawn_sky_sun(self, value): col random.randint(0, GRID_COLS - 1) x GRID_ORIGIN[0] col * CELL_W CELL_W // 2 self.drops.add(Sun(x, y-40, fall_speed60, valuevalue, life8.0)) def collect(self, pos): 鼠标点击时调用命中就加阳光 for s in list(self.drops): if s.rect.collidepoint(pos): self.sun s.value s.kill() return True return Falsewhile那一段是有意为之。如果写成if self.timer interval当某一帧dt稍微大一点比如 0.1 秒计时器每次只减一个interval剩下的零头在下一帧继续累积长期下来阳光产出会比设计值偏慢。用while把累积的时间全部消化掉产出曲线才是平的。Sun类本身很简单fall_speed控制下落速度life控制存活时间超时自动消失防止玩家不点导致屏幕上堆满阳光。落地后一般会停止下落源码里用self.y self.target_y判断target_y取格子中心高度这样阳光落点看起来是「掉在草坪上」而不是穿到屏幕底部。阳光的数值设计上自然掉落 25 一个、6 秒一次向日葵 50 阳光换 25 一次、约 8 秒产一次豌豆射手 100 阳光。也就是说开局 50 阳光最快也要等到第一个自然阳光落地才能种下第一株向日葵这个节奏感是刻意留的。你如果觉得开局太慢把SUN_INTERVAL改成 4.0 就能明显感觉到区别改完跑三波僵尸再决定要不要保留。4.2 网格种植卡槽冷却与占位判定卡槽数据用字典驱动一处配置、四处使用# data/cards.py from entities.plant import Sunflower, Peashooter, WallNut CARD_DEFS { sunflower: {cost: 50, cd: 7.5, cls: Sunflower}, peashooter: {cost: 100, cd: 7.5, cls: Peashooter}, wallnut: {cost: 50, cd: 30.0, cls: WallNut}, }种植逻辑放在BattleScene里每次点击先过三道判断def try_plant(self, key, cell): if cell is None: return False card CARD_DEFS[key] # 1. 冷却 if self.cooldowns.get(key, 0.0) 0: return False # 2. 阳光够不够 if self.sun_mgr.sun card[cost]: return False # 3. 这一格有没有东西 if cell in self.occupied: return False rect cell_to_rect(*cell) plant card[cls](rect.centerx, rect.centery) self.plants.add(plant) self.occupied[cell] plant self.sun_mgr.sun - card[cost] self.cooldowns[key] card[cd] return Truecooldowns用一个字典存每张卡的剩余冷却秒数update里统一递减for k in self.cooldowns: self.cooldowns[k] max(0.0, self.cooldowns[k] - dt)注意max(0.0, ...)冷却减到负数不影响判断但显示在卡槽上的进度条会算错保险起见钳住。occupied用(col, row)元组当 key指向植物实例。这个字典要双向维护植物被僵尸啃死时要del self.occupied[cell]否则会出现「格子空了但显示已占用、种不进去」的情况。这是我自己改这份代码时踩过的坑后面 5.2 节会展开。4.3 波次生成与僵尸状态机波次配置放在 JSON 里读出来是一个列表每项包含触发时间和这一波刷什么# data/level_1.json 的结构示例读取后是 Python 列表 WAVES [ {at: 8.0, spawn: [(normal, 4)]}, {at: 25.0, spawn: [(normal, 4), (cone, 3)]}, {at: 50.0, spawn: [(normal, 5), (cone, 4), (bucket, 2)]}, ](normal, 4)表示在这一行0 到 4 行刷一只普通僵尸行号随机也行源码里通常写成row由配置指定、-1表示随机。波次管理器的核心是一个elapsed计时器和已消费索引class WaveManager: def __init__(self, waves, zombie_factory): self.waves sorted(waves, keylambda w: w[at]) self.factory zombie_factory self.elapsed 0.0 self.index 0 def update(self, dt, group): self.elapsed dt while self.index len(self.waves) and self.elapsed self.waves[self.index][at]: wave self.waves[self.index] for kind, row in wave[spawn]: group.add(self.factory(kind, row)) self.index 1僵尸本身是个状态机只有三个状态走、啃、死。# entities/zombie.py class Zombie(pygame.sprite.Sprite): def __init__(self, x, y, speed20, hp200, bite25): super().__init__() self.x float(x) self.speed speed self.hp hp self.bite_damage bite self.state walk self.target None # 正在啃的植物 def update(self, dt): if self.state walk: self.x - self.speed * dt self.rect.x int(self.x) if self.x -60: # 走出屏幕左侧判负 self.state dead elif self.state eat: self.eat_timer dt if self.eat_timer 0.5: self.eat_timer - 0.5 if self.target and self.target.alive(): self.target.take_damage(self.bite_damage) else: self.state walk self.target Noneself.x用浮点单独存rect.x每帧取整同步。如果直接用rect.x - speed * dt在dt很小的时候会反复取整成 0僵尸看起来就是原地不动。这个细节几乎所有 pygame 教程都会踩。state从walk切到eat的判断通常放在场景层僵尸的rect和某一株植物的rect发生碰撞就设置target并把状态改成eat。切回去的条件是目标已经死亡或被移除代码里用self.target.alive()判断alive()是pygame.sprite.Sprite自带的方法精灵被kill()之后返回False。5. 常见问题排查五类跑不起来和跑起来不对的坑这份源码在别人机器上跑通不代表在你机器上跑通pygame 的坑集中在启动那几十秒和运行时的表现异常上。下面五条是我在不同机器上实际遇到过的按「现象 → 原因 → 解决」写遇到了直接对号入座。5.1 启动阶段的三类报错坑一满屏方块中文全是豆腐块。现象游戏能跑但所有中文文字显示成一个个方框或者问号。原因pygame.font.SysFont(simhei, 24)里的字体名是系统相关的macOS 和 Linux 上根本没有这个字体pygame 找不到就回退到一个不含中文字形的默认字体。解决把字体文件放进assets/fonts/用绝对路径加载别依赖系统字体FONT_PATH os.path.join(ASSETS_DIR, fonts, SourceHanSansCN-Regular.otf) _font_cache {} def get_font(size): if size not in _font_cache: _font_cache[size] pygame.font.Font(FONT_PATH, size) return _font_cache[size]字体文件建议用开源可商用的中文字体随项目一起打包换机器也不会出问题。坑二cannot convert without video mode。现象一启动就抛pygame.error: cannot convert without video mode。原因某个模块在pygame.display.set_mode()之前调用了convert()或convert_alpha()。常见于resource.py在模块顶层就加载图片而main.py里import发生在set_mode之前。解决图片加载写成惰性函数第一次调用时才真正读文件不要在模块导入时执行。坑三窗口一闪就退出控制台没有任何消息。现象python main.py回车后窗口闪一下消失。原因主循环里for event in pygame.event.get()正常但self.running被某个条件提前置为了False或者run()方法根本没被调用——入口if __name__ __main__:写成了if __name__ __main__:少一个等号这种低级错误。解决在run()的第一行和while循环里各打一个print确认代码真的进去了。如果没有输出问题在入口如果有输出但很快退出在self.running False的位置打日志。5.2 运行时的两类表现异常坑四僵尸穿过植物继续走 фун图看不出问题。现象僵尸明明和豌豆射手重叠了却不啃直接穿过去。原因僵尸的rect和植物的rect位置对了但碰撞判断用的是pygame.sprite.spritecollide且传了dokillTrue第一个碰到的植物被删了却没触发啃食逻辑或者僵尸的rect.x是浮点同步的、植物在格子中心两边的判定区域没对齐。解决把「谁和谁碰」的判断从spritecollide换成手动遍历判定范围用rect.inflate(-20, -10)收窄避免视觉上没碰到就触发def resolve_zombie_eat(self): for z in self.zombies: if z.state eat: continue for p in self.plants: if z.rect.inflate(-20, -10).colliderect(p.rect): z.target p z.state eat z.eat_timer 0.0 break坑五植物被啃死之后那一格再也种不上东西。现象向日葵被吃掉格子空了但鼠标点上去提示已占用或者没反应。原因occupied字典只在种植时写入植物死亡时没有同步删除。解决给植物加一个cell属性存它所在的网格坐标在cleanup_dead()里统一清理def cleanup_dead(self): for p in list(self.plants): if p.hp 0: if p.cell in self.occupied: del self.occupied[p.cell] p.kill()同时把僵尸的target置空不然僵尸会抱着一个已经kill()的对象继续啃空气。6. 二次开发加一株新植物并验证它的数值给这份源码加新内容最省事的路径是「配置优先代码兜底」先看新东西能不能通过改字典加进去实在不行再动类。我拿加一株冰豆射手举例它和豌豆射手 90% 的逻辑一样只是子弹带减速。第一步改配置CARD_DEFS里加一行# data/cards.py from entities.plant import Sunflower, Peashooter, WallNut, IcePeaShooter CARD_DEFS { sunflower: {cost: 50, cd: 7.5, cls: Sunflower}, peashooter: {cost: 100, cd: 7.5, cls: Peashooter}, wallnut: {cost: 50, cd: 30.0, cls: WallNut}, icepea: {cost: 175, cd: 7.5, cls: IcePeaShooter}, }第二步让子弹支持减速参数不新写类复用现有的# entities/bullet.py class Bullet(pygame.sprite.Sprite): def __init__(self, x, y, damage20, speed320, slowNone): super().__init__() self.image resource.load_image(pea.png) self.rect self.image.get_rect(center(x, y)) self.pos_x float(x) self.damage damage self.speed speed self.slow slow # 形如 (0.5, 3.0)表示速度减半持续 3 秒 def update(self, dt): self.pos_x self.speed * dt self.rect.x int(self.pos_x) if self.rect.left SCREEN_W: self.kill()slow参数是元组第一个值是速度倍率第二个是持续秒数。传入None就是普通豌豆传入(0.5, 3.0)就是冰豆。第三步写IcePeaShooter继承Peashooter只覆盖开火方法# entities/plant.py class IcePeaShooter(Peashooter): COST 175 FIRE_INTERVAL 1.4 def shoot(self): return Bullet(self.rect.centerx 20, self.rect.centery, damage20, slow(0.5, 3.0))第四步给僵尸的apply_slow补实现之前只有调用没有定义的话这里一并补上# entities/zombie.py def apply_slow(self, factor, duration): # 取更强的减速效果不叠加避免多株冰豆把僵尸定死 if factor self.slow_factor: self.slow_factor factor self.slow_timer duration def tick_slow(self, dt): if self.slow_timer 0: self.slow_timer - dt if self.slow_timer 0: self.slow_factor 1.0改完之后别急着开一局看效果数值类改动最好先做一轮无窗口验证。pygame 支持SDL_VIDEODRIVERdummy可以不开窗口跑逻辑# tools/verify_icepea.py import os os.environ[SDL_VIDEODRIVER] dummy # 必须在 import pygame 之前设置 import pygame pygame.init() pygame.display.set_mode((1, 1)) from entities.bullet import Bullet from entities.zombie import Zombie z Zombie(x600, y200, speed40, hp200) b Bullet(x100, y200, damage20, slow(0.5, 3.0)) z.take_damage(b.damage) z.apply_slow(*b.slow) for _ in range(60): # 模拟 60 帧每帧 1/60 秒 z.tick_slow(1 / 60) z.update(1 / 60) assert z.hp 180, f伤害结算异常: {z.hp} assert abs(z.slow_factor - 0.5) 1e-6, 减速未生效 print(icepea ok, x , round(z.x, 2))跑通这个脚本再去开窗口能省掉大量「改了代码、开游戏、发现没效果、回去加 print」的来回。我后来养成一个习惯任何涉及数值的改动都先在 dummy 驱动下写一个断言脚本跑一遍尤其是碰撞和计时相关的逻辑肉眼在 60 帧里根本分不清是减速了还是没减速、是伤害 20 还是 25。改造过程中还有个容易忽略的点新植物要进 UI 卡槽。卡槽一般是从CARD_DEFS遍历生成的加了字典就自动多一格但如果卡槽位置是硬编码的range(3)新卡就不会显示。检查scenes/battle.py里画卡槽的循环确认它读的是CARD_DEFS的长度而不是写死的数字。这种「数据加了、界面没加」的问题比逻辑错误更隐蔽因为游戏不报错只是新东西永远不出现在你面前。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑