简介:这是一份面向C++游戏开发初学者与2D射击游戏爱好者的完整源码工程,实现了一个带视野遮挡效果的自上而下射击玩法,适合用来学习游戏循环、碰撞检测、光照与阴影渲染等核心机制。资源包共76个文件,约140KB,以cpp与h源码文件为主,配合png精灵图、pgm/ppm光照贴图、frag着色器、svg矢量图及Makefile构建脚本,结构上分为headers、src、sprites、shaders、maps等模块,便于按功能阅读与二次开发。项目基于SFML与Box2D,在Linux下通过apt安装依赖后make即可编译运行,同时附带可在32位或64位Windows上直接运行的二进制文件。目前已有369人学习下载,读者可从中获得完整的游戏主循环、敌人与子弹管理、小地图、生命条、地图生成及基于着色器的动态光照实现思路,是研究2D视野与光影效果的实用参考案例。
1. 从零手搓一个 TopDownShooter:为什么我劝你先别急着写玩家移动
很多人第一次做 TopDownShooter,脑子里想的是“俯视角、2D、射击”三个词,打开引擎就先把玩家角色拖进场景,然后开始写 WASD 移动。结果两小时过去,角色能跑了,子弹能飞了,但敌人像木头一样站着,碰撞检测时有时无,子弹穿墙,玩家能走出地图边界。这不是你代码写得差,而是顺序错了。
TopDownShooter 本质上是一个“状态密集”的小型系统:玩家有位置、朝向、生命值、射击冷却;子弹有速度、存活时间、碰撞层;敌人有寻路、攻击间隔、受击反馈。这些状态之间互相耦合,任何一个没设计好,后面就是血泪经验。我一般会先花二十分钟把数据流画清楚,再动手写第一行代码。
这篇文章面向两类人:一是刚学完基础语法、想找个完整项目练手的开发者;二是做过其他类型游戏、但没碰过俯视角射击的熟手。我会用 Python + Pygame 作为实现载体,因为它足够轻,能把逻辑暴露得清清楚楚,不会让你把时间浪费在引擎的玄学配置上。如果你用的是 Unity 或 Godot,逻辑层完全一样,只是 API 换了个名字。
2. 先定坐标系和输入映射:俯视角射击的地基
2.1 为什么屏幕坐标和世界坐标必须分开
新手最容易翻车的地方,是把鼠标位置直接当成玩家朝向。在俯视角游戏里,鼠标屏幕坐标是相对于窗口左上角的,而玩家在世界里的位置可能因为镜头滚动而偏移。如果你不把两者统一到一个坐标系,就会出现“鼠标在屏幕中间,子弹却往左上飞”的诡异现象。
常见做法是维护一个 camera_offset 向量,所有世界物体渲染时减去它,所有鼠标输入加上它。这样镜头移动时,玩家朝向依然准确。我一般会在游戏循环最开始就更新这个偏移量,而不是等到渲染时才算。
# 坐标系转换核心逻辑 import pygame from pygame.math import Vector2 class Camera: def __init__(self, screen_width, screen_height): self.offset = Vector2(0, 0) self.screen_center = Vector2(screen_width // 2, screen_height // 2) def world_to_screen(self, world_pos): # 世界坐标转屏幕坐标:减去镜头偏移 return world_pos - self.offset + self.screen_center def screen_to_world(self, screen_pos): # 屏幕坐标转世界坐标:加上镜头偏移 return screen_pos + self.offset - self.screen_center def update(self, target_pos): # 镜头跟随玩家,保持玩家在屏幕中心 self.offset = target_pos - self.screen_center这段代码里,world_to_screen和screen_to_world是一对逆运算。参数target_pos是玩家在世界里的位置,screen_center是窗口中心点。注意update方法里没有做平滑插值,如果你想要镜头有延迟感,可以把self.offset改成向目标位置 lerp,但那样会让鼠标瞄准产生轻微滞后,射击游戏里慎用。
2.2 输入映射:WASD 移动和鼠标瞄准的分离
俯视角射击的输入有两个独立通道:键盘控制移动,鼠标控制朝向和射击。很多人会把两者混在一个update里,导致移动时朝向抖动。正确的做法是每帧先读键盘向量,归一化后乘以速度,得到位移;再读鼠标位置,转成世界坐标,减去玩家位置,得到朝向向量。
# 玩家输入处理 def handle_input(self, camera): keys = pygame.key.get_pressed() move_dir = Vector2(0, 0) if keys[pygame.K_w]: move_dir.y -= 1 if keys[pygame.K_s]: move_dir.y += 1 if keys[pygame.K_a]: move_dir.x -= 1 if keys[pygame.K_d]: move_dir.x += 1 if move_dir.length_squared() > 0: move_dir = move_dir.normalize() # 防止斜向移动加速 self.pos += move_dir * self.speed * self.dt # 鼠标朝向 mouse_screen = Vector2(pygame.mouse.get_pos()) mouse_world = camera.screen_to_world(mouse_screen) self.aim_dir = (mouse_world - self.pos).normalize()move_dir.normalize()是关键,不归一化的话,同时按 W 和 D 会让速度变成 1.414 倍,老玩家一眼就能看出不对劲。self.dt是帧间隔时间,用clock.tick(60) / 1000.0得到,这样不同帧率下移动速度一致。aim_dir存成单位向量,后面子弹发射直接用它乘以子弹速度。
注意:
pygame.mouse.get_pos()返回的是整数,如果窗口有缩放或高 DPI 设置,需要先做一次坐标换算,否则瞄准会有几个像素的偏差。这个坑在 4K 屏上特别明显。
3. 子弹、敌人和碰撞:三个最容易写乱的模块
3.1 子弹的生命周期管理:别让列表无限增长
子弹是典型的“短命对象”,发射后飞行一段距离或命中敌人就消失。新手常犯的错误是只往列表里 append,从不 remove,跑几分钟后帧率暴跌。我一般用“标记删除”法:每颗子弹有一个alive标志,每帧更新后统一清理。
class Bullet: def __init__(self, pos, direction, speed=600, lifetime=1.5): self.pos = Vector2(pos) self.dir = Vector2(direction) self.speed = speed self.lifetime = lifetime self.alive = True self.radius = 4 def update(self, dt): self.pos += self.dir * self.speed * dt self.lifetime -= dt if self.lifetime <= 0: self.alive = False # 游戏循环中的清理 bullets = [b for b in bullets if b.alive]lifetime默认 1.5 秒,按 600 像素/秒算,射程约 900 像素,足够覆盖一个屏幕。如果你的地图更大,可以调到 2.0,但别超过 3.0,否则子弹飞出视野还在计算,浪费性能。radius用于碰撞检测,比用矩形更符合子弹的视觉表现。
3.2 敌人追踪:用向量减法代替三角函数
很多教程教敌人追踪时用math.atan2算角度,再转成方向向量。这没错,但多了一步。更直接的做法是:敌人位置减去玩家位置,归一化,乘以速度。这样代码更短,也不容易在角度制/弧度制上翻车。
class Enemy: def __init__(self, pos, speed=120, hp=3): self.pos = Vector2(pos) self.speed = speed self.hp = hp self.alive = True def update(self, player_pos, dt): to_player = player_pos - self.pos if to_player.length_squared() > 0: direction = to_player.normalize() self.pos += direction * self.speed * dtspeed设 120 比玩家稍慢,给玩家反应时间。hp为 3 意味着需要三发子弹,配合 0.2 秒射击间隔,大约 0.6 秒击杀一个敌人。如果你想要更紧张的感觉,把speed提到 150,但别超过玩家速度,否则玩家无法拉开距离,体验会变成“被追着打”。
3.3 碰撞检测:圆形检测在俯视角里的优势
俯视角游戏里,角色和子弹都是俯视投影,用圆形碰撞比矩形更自然。矩形碰撞在斜向接触时会出现“空气墙”或“提前命中”。圆形检测只需要比较圆心距离和半径之和。
def check_collision(bullets, enemies): for bullet in bullets: if not bullet.alive: continue for enemy in enemies: if not enemy.alive: continue dist_sq = (bullet.pos - enemy.pos).length_squared() min_dist = bullet.radius + 16 # 敌人半径按 16 算 if dist_sq < min_dist * min_dist: bullet.alive = False enemy.hp -= 1 if enemy.hp <= 0: enemy.alive = False break # 一颗子弹只命中一个敌人length_squared()比length()少一次开方,在子弹和敌人数量多时能省不少 CPU。break保证子弹穿透问题不会发生,除非你故意要做穿透武器。敌人半径 16 是经验值,对应 32x32 的精灵图,如果你的素材更大,记得同步改。
4. 避坑与排查:TopDownShooter 里那些让人摔键盘的瞬间
4.1 子弹从玩家身后飞出来
现象:按下射击键,子弹出现在玩家背后,而不是枪口位置。原因:子弹初始位置直接用了玩家中心,但玩家精灵的枪口在朝向方向偏移了 20 像素。解决:发射时用player.pos + player.aim_dir * 20作为子弹起点。这个偏移量要根据你的精灵图调整,我一般会在编辑器里量一下枪口到中心的像素距离。
4.2 敌人叠在一起变成一坨
现象:多个敌人追玩家时完全重合,看起来像一个敌人。原因:敌人之间没有分离逻辑。解决:在敌人 update 里加一个简单的排斥力,遍历其他敌人,如果距离小于 32,就沿反方向推一点。不需要物理引擎,几行代码就能让敌人散开。
# 敌人分离 for other in enemies: if other is self: continue diff = self.pos - other.pos if 0 < diff.length_squared() < 32 * 32: self.pos += diff.normalize() * 50 * dt4.3 射击间隔靠 sleep 导致整个游戏卡住
现象:按住鼠标,游戏画面一顿一顿的。原因:用了time.sleep(0.2)来控制射速。解决:用计时器累加dt,超过冷却时间才允许发射。time.sleep会阻塞整个主线程,在游戏循环里是禁忌。
self.shoot_timer -= dt if mouse_pressed and self.shoot_timer <= 0: fire_bullet() self.shoot_timer = 0.2 # 射击间隔4.4 窗口关闭后进程还在后台跑
现象:点了关闭按钮,窗口没了,但任务管理器里进程还在。原因:Pygame 的QUIT事件没有正确处理,或者游戏循环没有退出。解决:在事件循环里检测pygame.QUIT,设置running = False,并在循环结束后调用pygame.quit()和sys.exit()。
4.5 帧率不稳定导致移动距离不一致
现象:高帧率时角色跑得飞快,低帧率时像慢动作。原因:移动没有乘以dt。解决:所有涉及速度的计算都乘以dt,包括玩家移动、子弹飞行、敌人追踪。dt用clock.tick(60) / 1000.0获取,单位是秒。
5. 从能跑到好玩:三个提升手感的具体技巧
5.1 屏幕震动:让射击有反馈
射击游戏的手感很大一部分来自反馈。屏幕震动是最便宜的实现方式:每次发射或命中时,给 camera offset 加一个随机小向量,持续几帧后衰减。
class Camera: def __init__(self): self.shake_intensity = 0 self.shake_decay = 8.0 def add_shake(self, amount): self.shake_intensity = min(self.shake_intensity + amount, 12) def update(self, target_pos, dt): self.offset = target_pos - self.screen_center if self.shake_intensity > 0: import random shake = Vector2( random.uniform(-1, 1) * self.shake_intensity, random.uniform(-1, 1) * self.shake_intensity ) self.offset += shake self.shake_intensity -= self.shake_decay * dt if self.shake_intensity < 0: self.shake_intensity = 0add_shake(3)用于普通射击,add_shake(6)用于敌人死亡。shake_decay控制衰减速度,8.0 大约在 0.4 秒内从 3 衰减到 0。注意震动是加在 offset 上的,不要改target_pos,否则镜头会漂移。
5.2 敌人受击闪白:用颜色叠加代替动画
没有美术资源时,受击反馈可以用颜色叠加实现。命中时把敌人精灵的special_flags设为BLEND_RGB_ADD,叠加一个白色,持续 0.1 秒。
# 受击闪白 if enemy.hit_flash_timer > 0: sprite.fill((255, 255, 255, 0), special_flags=pygame.BLEND_RGB_ADD) enemy.hit_flash_timer -= dtBLEND_RGB_ADD会把白色加到原图上,产生过曝效果。hit_flash_timer在命中时设为 0.1,每帧递减。这个技巧对任何颜色的敌人都有效,不需要额外素材。
5.3 用状态机管理游戏流程
当游戏从“进行中”切换到“游戏结束”时,如果只用if game_over判断,代码会越来越乱。我一般用一个简单的状态机:STATE_PLAYING、STATE_GAMEOVER、STATE_PAUSED。每个状态有自己的 update 和 draw 逻辑。
class GameState: PLAYING = 0 GAMEOVER = 1 PAUSED = 2 # 主循环 if state == GameState.PLAYING: update_game(dt) draw_game() elif state == GameState.GAMEOVER: draw_gameover_screen()状态切换只改一个变量,避免了几十个布尔标志互相打架。这个习惯我从做第一个 TopDownShooter 就养成了,后来做再复杂的项目也没翻过车。
5.4 性能验证:用帧时间而不是帧率
很多人用clock.get_fps()看性能,但帧率是平均值,会掩盖瞬时卡顿。更准确的做法是记录每帧的dt,看最大值。如果dt超过 0.033 秒(30 FPS),说明有帧卡顿。
frame_times = [] # 每帧 frame_times.append(dt) if len(frame_times) > 60: frame_times.pop(0) max_dt = max(frame_times) if max_dt > 0.033: print(f"卡顿帧: {max_dt*1000:.1f}ms")这个习惯帮我定位过很多问题:子弹列表没清理、碰撞检测双重循环、精灵图太大导致 blit 慢。希望帮到你。
本文还有配套的精品资源,点击获取