从零手搓简单射击游戏:Python游戏循环与碰撞判定实战
发布时间:2026/9/26 11:39:39来源:尧图网络
简介这是一份面向初学者的j2ME手机射击游戏完整源码以飞机为主角适合想入门移动游戏开发、理解游戏编程基础流程的读者练手。资源包共200个文件约4.27MB包含11个java源文件、22个class编译文件、138个png图片素材、20个mid音频以及jad、jar等打包文件覆盖代码、美术与音效资源可直接导入EclipseMe环境运行调试。代码围绕游戏循环、精灵对象、碰撞检测、用户输入处理、Graphics图形渲染、音频播放与状态管理等核心知识点展开并附有textPlan设计计划文件便于对照理解游戏逻辑与实现思路。目前已有1950人学习下载初学者可借此掌握j2ME中MIDP与CLDC的基本用法有经验的开发者也可参考其资源组织与性能处理方式。1. 从零手搓一款简单射击类游戏为什么我劝你先别急着上引擎很多人搜“一款简单射击类游戏代码”脑子里想的是打开某个引擎、拖几个控件、挂上碰撞体就完事。但我带过的新人里十个有八个卡在“装引擎、配环境、导入素材”这三步上最后连一行游戏逻辑都没写出来。其实射击游戏的内核非常朴素一个能移动的玩家、一批会下落的敌人、能发射的子弹、碰撞判定、分数和结束条件。把这五件事用纯代码写清楚比在引擎里点来点去更能让你理解游戏循环到底在干什么。这篇笔记就按“先立住原理、再动手复现”的路子走用 Python 加一个轻量窗口库把一款简单射击类游戏从零跑通。适合刚学完基础语法、想找个能看见成果的练手项目的人也适合想回头补一补游戏循环底层逻辑的熟手。全程不依赖大型引擎代码可直接抄参数我会逐个说明白。2. 射击游戏的最小骨架游戏循环、坐标系与帧率2.1 为什么游戏循环是所有射击游戏的命根子任何一款射击类游戏不管画面多花哨底层都是一个死循环在转。这个循环每转一圈叫一帧一帧里干三件事处理输入、更新状态、重绘画面。玩家按了方向键输入阶段记下来更新阶段根据输入改玩家坐标同时让子弹往上飞、敌人往下落重绘阶段把新的坐标画到窗口上。循环转得够快人眼就看到连续运动。这里有个关键量叫帧率也就是每秒转多少圈。帧率太低子弹会一顿一顿帧率太高CPU 空转浪费。常见做法是把帧率锁在 60也就是每帧约 16.7 毫秒。我一般会在循环里加一个时钟对象来限速而不是用 sleep 硬等因为 sleep 的精度在不同系统上玄学得很容易让运动速度飘忽。坐标系要提前定死。窗口左上角是原点 (0,0)x 向右增大y 向下增大。这点和数学课本上的 y 轴方向相反新手最容易在这里翻车以为 y 越大越靠上结果子弹往屏幕外飞。记住一句话屏幕坐标里“上”是 y 减小“下”是 y 增大。玩家在底部所以玩家的 y 是个较大的值子弹要往上打所以子弹每帧的 y 要减去一个速度值敌人从顶部来y 从 0 开始每帧加上速度值。把这三个方向关系在纸上画一遍后面写代码基本不会错。帧率还牵扯到一个速度单位问题。如果你把子弹速度写成“每帧移动 8 像素”那在 60 帧和 120 帧的机器上子弹实际飞行速度会差一倍。稳妥的做法是把速度定义成“每秒移动多少像素”更新时乘以两帧之间的时间差。这样无论帧率怎么变运动速度都一致。这个时间差通常叫 delta time单位是秒。下面这段就是最小循环骨架先把它跑起来确认窗口能开、能关、帧率稳定再往里填内容。import pygame import sys pygame.init() screen pygame.display.set_mode((480, 640)) # 窗口宽480 高640 pygame.display.set_caption(简单射击) clock pygame.time.Clock() running True while running: dt clock.tick(60) / 1000.0 # 返回毫秒除以1000换成秒 for event in pygame.event.get(): if event.type pygame.QUIT: running False screen.fill((10, 10, 30)) # 深色背景 # 后续的更新与绘制都写在这里 pygame.display.flip() pygame.quit() sys.exit()逻辑说明clock.tick(60)做两件事一是把循环限速到最多 60 帧每秒二是返回距离上一帧过了多少毫秒。除以 1000 得到秒就是 dt。参数说明窗口尺寸 480×640 是竖版射击的常见比例横版可以改成 640×480fill的颜色是 RGB 三元组这里用深蓝黑做背景方便看清亮色子弹。pygame.display.flip()负责把这一帧画好的内容真正显示出来漏了它窗口会一片黑。跑通这一步你会看到一个深色窗口关掉它程序正常退出骨架就算立住了。2.2 玩家、子弹、敌人用类把三种实体分开骨架有了接下来往里塞实体。射击游戏里玩家、子弹、敌人行为差别很大用类来封装最清楚。玩家需要响应键盘、限制在窗口内子弹只需要一个位置和速度飞出屏幕就销毁敌人从顶部随机 x 出现往下落出底部就消失或扣血。三者共同点是都有坐标和尺寸所以可以约定每个类都提供rect属性和update方法主循环统一遍历调用。这种“约定接口”的做法在纯代码游戏里特别省心后面加新实体只要遵守约定就能接进去。玩家移动要处理一个边界问题。如果只写“按左键 x 减速度”玩家能一路移出屏幕左边。常见做法是移动后把 x 夹在 0 和“窗口宽减玩家宽”之间。夹取用max(0, min(x, 上限))一行搞定。键盘输入有两种读法事件式KEYDOWN 触发一次和状态式pygame.key.get_pressed()每帧读当前按住的键。射击游戏里玩家移动要连续必须用状态式开火如果要做成按一下打一发用事件式更合适否则按住会每帧都发射子弹密得像泼水。这个区别是新手高频踩坑点先记牢。子弹的生成时机放在开火事件里生成位置对准玩家顶部中央这样视觉上是从枪口出来。子弹速度设成每秒 600 像素左右比较跟手太快看不清弹道太慢打不到敌人。敌人下落速度从每秒 120 像素起步随着分数提高再加快这是后面难度曲线的伏笔。下面把三个类补齐并接进主循环。class Player: def __init__(self, x, y): self.rect pygame.Rect(x, y, 40, 40) self.speed 300 # 像素/秒 def update(self, dt, keys): if keys[pygame.K_LEFT]: self.rect.x - self.speed * dt if keys[pygame.K_RIGHT]: self.rect.x self.speed * dt # 夹在窗口内 self.rect.x max(0, min(self.rect.x, 480 - self.rect.width)) class Bullet: def __init__(self, x, y): self.rect pygame.Rect(x, y, 4, 12) self.speed 600 def update(self, dt): self.rect.y - self.speed * dt # 向上飞y减小 class Enemy: def __init__(self, x, y): self.rect pygame.Rect(x, y, 36, 36) self.speed 120 def update(self, dt): self.rect.y self.speed * dt # 向下落y增大逻辑说明三个类的update都接收 dt保证速度与帧率无关。玩家额外接收 keys因为移动依赖当前按键状态。参数说明玩家 40×40、敌人 36×36、子弹 4×12这些尺寸决定碰撞手感和视觉比例可以按自己审美调但子弹别做得太宽否则判定过于宽松会失去紧张感。玩家速度 300 像素每秒在 480 宽的窗口里横穿大约 1.6 秒手感适中。子弹 600 像素每秒从底部到顶部约 1 秒出头给玩家留出反应时间。把这些对象放进列表统一管理主循环里先更新再绘制顺序别反否则会看到实体“慢一帧”。3. 碰撞判定与游戏状态机让子弹真的能打中3.1 矩形碰撞够用但判定顺序有讲究射击游戏的碰撞判定绝大多数情况用矩形相交就够了不需要像素级精确。pygame.Rect自带colliderect方法两个矩形有重叠就返回真。子弹打敌人、敌人撞玩家都用它。但判定顺序会影响结果这点很多人没注意。正确顺序是先更新所有实体位置再做碰撞检测最后统一清理被销毁的对象。如果你边更新边删除列表在遍历中被改动轻则漏判重则直接报错。血泪经验是永远不要在遍历列表的同时删元素要么用列表推导重建要么先标记再统一删。子弹命中敌人后子弹和敌人要同时消失分数加一。这里有个细节一颗子弹可能在同一帧里和两个敌人重叠如果你用嵌套循环逐个判定一颗子弹会打掉两个敌人。常见做法是命中一个就让子弹失效并跳出内层循环。敌人撞玩家则触发游戏结束把状态切到结束态。判定用的矩形可以比视觉图形略小一圈这叫“判定盒收缩”能让玩家觉得“擦边没死”是运气好提升手感。收缩量一般取 2 到 4 像素太多会让子弹看着穿过敌人却不掉血反而像 bug。下面这段是碰撞处理的核心注意它和主循环的配合方式。score 0 game_over False def handle_collisions(bullets, enemies, player): global score, game_over # 子弹打敌人 for b in bullets[:]: # 复制一份遍历避免边删边改 hit False for e in enemies[:]: if b.rect.colliderect(e.rect): bullets.remove(b) enemies.remove(e) score 1 hit True break # 一颗子弹只打一个敌人 if hit: continue # 敌人撞玩家 for e in enemies: if e.rect.colliderect(player.rect): game_over True break逻辑说明bullets[:]和enemies[:]是浅拷贝遍历拷贝、修改原列表这是避免遍历中删元素翻车的标准写法。break保证一颗子弹只结算一次命中。参数说明score和game_over用全局变量是为了示例简洁实际项目里建议封进一个 Game 类但练手阶段全局变量更直观。判定盒收缩这里没写如果要加在创建 rect 时把宽高各减 4 即可。注意敌人撞玩家的判定放在子弹判定之后这样同一帧里玩家先有机会打掉敌人体验更公平。3.2 用状态机管住开始、进行、结束三种局面游戏不能一上来就开打也不能死了还继续。用状态机把流程分成三个状态开始、进行中、结束。开始态显示提示文字按任意键进入进行态进行态跑完整的更新和碰撞结束态显示最终分数按键重开。状态机的好处是所有逻辑分支集中在一处不会出现“死了还能移动”这种鬼畜现象。实现上用一个字符串变量存当前状态主循环里根据状态决定更新哪些东西、画哪些东西。状态切换要清场。从结束态重开时必须把子弹列表、敌人列表清空分数归零玩家位置复位。如果忘了清上一局的子弹会留在屏幕上接着飞看起来像灵异事件。我一般写一个reset_game函数专门干这件事重开时调一次保证初始状态干净。生成敌人的节奏也要管起来常见做法是用一个计时器累加 dt每过一定间隔生成一个间隔随分数缩短形成难度爬升。间隔从 1.2 秒逐步降到 0.4 秒左右再快就变成弹幕不适合“简单射击”的定位。state start spawn_timer 0.0 spawn_interval 1.2 def reset_game(): global bullets, enemies, score, game_over, spawn_timer, spawn_interval bullets, enemies [], [] score 0 game_over False spawn_timer 0.0 spawn_interval 1.2 player.rect.x 220 player.rect.y 580逻辑说明reset_game把所有可变状态一次性归位重开时调用它就不会有残留。参数说明spawn_interval初始 1.2 秒每次得分后可以乘 0.98 逐步缩短或者按分数分档硬设。玩家复位坐标 220 是窗口宽 480 减玩家宽 40 再除以 2 得到的居中值580 是贴近底部。状态字符串用英文避免编码问题显示给玩家的文字再用中文。这套状态机虽然朴素但已经能撑起一款完整可玩的射击游戏后面加道具、加多命都是在这个骨架上挂。4. 参数调优与难度曲线让游戏“能玩”而不是“能跑”4.1 三个决定手感的数值玩家速度、子弹速度、敌人生成间隔代码能跑不等于游戏能玩。我见过太多练手项目逻辑全对但玩起来要么太简单要么劝退问题都出在数值上。玩家速度决定走位灵活度300 像素每秒在 480 宽窗口里偏慢适合新手想更跟手可以提到 400但超过 500 会难以精确站位。子弹速度决定打击反馈600 像素每秒是甜点值低于 400 会感觉子弹“飘”高于 900 则几乎瞬间命中失去预判乐趣。敌人生成间隔决定压力1.2 秒起步让玩家有喘息逐步压到 0.4 秒时屏幕上的敌人数量会明显增多这时候如果玩家速度没跟上就会觉得“躲不掉”。这三个值不是独立的要联动调。比如你把敌人间隔压到 0.5 秒那玩家速度最好不低于 350否则横向移动来不及清场。子弹速度如果调低敌人间隔就要相应放宽不然子弹还没飞到敌人已经压到眼前。我一般会先定玩家速度再定子弹速度最后用敌人间隔去凑难度。调的时候别凭感觉开一局记录存活时间和得分目标定在“新手第一局能活 20 到 30 秒、得 5 到 10 分”这个区间既有挫败感又不至于砸键盘。提示调参时把 spawn_interval 的缩短逻辑先注释掉用固定值测基础手感基础手感对了再开难度爬升否则两个变量一起变你根本不知道是哪个出了问题。4.2 难度爬升的两种做法线性缩间隔还是分档加速难度爬升常见两种做法。第一种是线性缩间隔每得一分就把 spawn_interval 乘以一个小于 1 的系数比如 0.98。好处是平滑坏处是后期会缩到极小敌人像下雨。第二种是分档比如 0 到 5 分间隔 1.2 秒6 到 15 分间隔 0.9 秒16 分以上 0.6 秒。分档的好处是每档有明确的压力台阶玩家能感知到“变难了”也方便你控制上限。我一般用分档因为简单射击的定位不需要无限难度给个封顶更合理。除了间隔敌人下落速度也可以参与难度。但下落速度影响的是反应时间间隔影响的是同时在场数量两者叠加会陡增难度。稳妥做法是前期只缩间隔分数过半后再小幅提下落速度每次加 10% 左右。另外可以给敌人加一点横向漂移让它们不是直线下落增加走位需求但这属于进阶先把直线下落调明白再说。下面是一个分档难度的实现片段。def current_interval(score): if score 6: return 1.2 elif score 16: return 0.9 else: return 0.6 # 主循环更新部分 spawn_timer dt if spawn_timer current_interval(score): spawn_timer 0.0 x random.randint(0, 480 - 36) enemies.append(Enemy(x, -36))逻辑说明current_interval根据当前分数返回生成间隔主循环累加计时器超过间隔就生成一个敌人并重置计时器。参数说明敌人初始 y 设为 -36也就是在窗口上方刚好藏住这样它是“落进来”而不是“凭空出现”视觉更自然。random.randint的上限是窗口宽减敌人宽保证敌人完整出现在画面内。分档阈值 6 和 16 是经验值可以按自己节奏改但档位别太多三档足够。5. 避坑与排查那些让简单射击游戏跑不起来的常见问题5.1 窗口一片黑什么都不显示现象程序能启动窗口也开了但里面全黑看不到玩家和敌人。原因通常有三个。第一忘了调pygame.display.flip()或pygame.display.update()画的内容停在后台缓冲没送到屏幕。第二绘制顺序错了先画背景fill之后又画了别的东西盖住或者实体坐标在屏幕外。第三颜色值写成了 0 到 255 之外比如用了 300导致绘制异常。解决先确认 flip 在循环末尾再打印玩家和敌人的 rect 坐标看是不是负数或超出窗口。坐标对、flip 有、还是黑就检查 fill 的颜色三元组每个值是否在 0 到 255。5.2 子弹按住不放像泼水或者按一下没反应现象按住开火键子弹每帧都生成密密麻麻或者按一下完全没子弹。原因开火逻辑写在了状态式检测里或者写在了事件式里但判断条件写反。状态式get_pressed每帧都返回按住状态放进去必然连发。正确做法是用事件式在KEYDOWN事件里生成子弹这样按一下只触发一次。如果按一下没反应检查事件类型是不是写成了KEYUP或者键码写错。解决把开火放进事件循环用event.type pygame.KEYDOWN and event.key pygame.K_SPACE判断。5.3 碰撞判定时程序崩溃报列表长度变化现象子弹打中敌人瞬间程序报错提示列表在迭代中被修改。原因在for b in bullets循环里直接bullets.remove(b)遍历和修改同时进行。解决用bullets[:]拷贝一份再遍历或者用列表推导重建bullets [b for b in bullets if not b.hit]。我一般用拷贝遍历直观且不容易写错。这个坑几乎每个新手都会踩一次记住“遍历用拷贝修改用原列表”就行。5.4 游戏结束后还能移动或者重开后上一局子弹还在现象敌人撞到玩家分数定格了但玩家还能左右移动或者按重开键后屏幕上残留上一局的子弹。原因状态切换后没有在主循环里用状态做分支更新逻辑照跑不误重开时没有清空列表。解决主循环里用if state playing:包住更新和碰撞逻辑非进行态只处理输入和绘制。重开时调用reset_game清空所有列表和计时器。这两个问题本质都是状态没管干净状态机的价值就体现在这里。5.5 帧率不稳子弹速度忽快忽慢现象同样的代码在不同电脑上子弹速度不一样或者同一台机器上偶尔卡顿。原因速度写成了“每帧多少像素”没有乘 dt或者用了time.sleep限速精度差。解决所有运动都改成“每秒多少像素”更新时乘 dt。限速用clock.tick(60)不要用 sleep。如果还是卡检查循环里有没有耗时操作比如每帧加载图片或做大循环计算。简单射击游戏纯用矩形绘制正常不会卡卡了基本都是代码里有隐藏的重活。6. 进阶技巧用对象池和事件解耦把代码撑到能改写到这儿游戏已经能玩但如果你还想加道具、加多种敌人、加音效直接往主循环里堆代码很快就会乱成一团。我踩过的坑是一开始图省事把所有逻辑塞进 while 循环加到第五种敌人时改一个地方崩三个地方。后来学乖了用两个手段解耦。第一个是对象池子弹和敌人频繁创建销毁虽然 Python 的垃圾回收能扛但量大时会有微小卡顿。对象池的思路是预先创建一批对象不用时标记为“失效”而不是删除需要时复用。对简单射击来说子弹池和敌人池各准备 50 个就够用active标志控制生死遍历时跳过失效对象。第二个是事件解耦。得分、游戏结束、生成敌人这些事不要在主循环里硬编码而是定义成事件由一个简单的调度器分发。比如子弹命中敌人时只发一个“enemy_killed”事件分数模块监听它加分难度模块监听它缩间隔音效模块监听它播音效。这样加新功能只要注册监听不用动碰撞代码。Python 里用字典存事件名到回调列表的映射就能实现几十行搞定。下面是一个极简事件调度器配合对象池的标记位使用。listeners {} def on(event_name, callback): listeners.setdefault(event_name, []).append(callback) def emit(event_name, payloadNone): for cb in listeners.get(event_name, []): cb(payload) # 注册示例 def add_score(_): global score score 1 def speed_up(_): global spawn_interval spawn_interval max(0.4, spawn_interval * 0.98) on(enemy_killed, add_score) on(enemy_killed, speed_up) # 碰撞处改为 emit(enemy_killed)逻辑说明on注册回调emit触发所有回调。碰撞代码不再直接改分数和间隔只发事件职责清晰。参数说明spawn_interval用max(0.4, ...)兜底防止缩到负数或过小。对象池的active标志在遍历时用if not b.active: continue跳过。这套结构的好处是你后面想加“击杀音效”只要再on(enemy_killed, play_sound)一行注册碰撞代码一个字不用改。验证方法也简单注册一个只打印的回调看事件有没有按预期触发触发次数对不对。我自己的习惯是任何练手项目写到能跑之后都强迫自己做一次“加一个新功能”的演练比如给这款简单射击加一种会横向移动的敌人。如果加它需要改超过三个地方说明结构该重构了。这个习惯帮我省了无数后悔药。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网