当前位置:首页 > 技术 > 正文内容

基于 tkinter 库的 FPS 射击类打地鼠游戏开发详解

访客 技术 2026年11月5日 1

在 Python 综合实践中,开发一款具备 FPS(第一人称射击)元素的"打地鼠"游戏是检验图形界面编程与逻辑处理能力的有效方式。本项目利用 Python 标准库 tkinter 结合 math 与 random 模块,实现了一个包含准星跟随、爆头判定、动态难度调整以及数据持久化功能的综合应用。

1. 游戏机制设计

  • 射击反馈: 游戏模拟 FPS 操作,鼠标指针被隐藏,取而代之的是实时跟随的空心十字准星。
  • 分级伤害判定: 敌人由"头部"和"身体"两部分组成。击中头部(圆形区域)判定为爆头,一击必杀且得分最高;击中身体(矩形区域)需两枪才能消灭,增加了射击的策略性。
  • 动态难度平衡: 系统每 20 秒提升一次难度等级,通过缩短敌人的生成间隔和存在时间,不断挑战玩家的反应极限。
  • 持久化存储: 游戏结束后,系统会自动比对并更新本地存储的最高分记录。

2. 核心算法实现

2.1 碰撞检测逻辑

为了实现精准的 FPS 射击体验,程序采用了双重碰撞检测算法。首先通过欧几里得距离公式判断是否命中头部圆心区域,若未命中,再进行矩形边界框判定:

# 头部判定(圆形碰撞)
distance = math.hypot(mouse_x - head_center_x, mouse_y - head_center_y)
if distance <= head_radius:
    is_headshot = True

# 身体判定(矩形碰撞)
if body_left <= mouse_x <= body_right and body_top <= mouse_y <= body_bottom:
    is_body_hit = True

2.2 动态难度调度

游戏循环基于 tkinter.after 实现,每 16ms 刷新一次。难度系数随时间线性增长,影响两个关键参数:spawn_rate(生成速率)和 stay_duration(停留时长)。

3. 完整源代码实现

import tkinter as tk
import random
import math
import time
import os

# 配置常量
WINDOW_WIDTH, WINDOW_HEIGHT = 1000, 650
HOLE_LOCATIONS = [
    (150, 150), (350, 150), (550, 150), (750, 150), (900, 150),
    (250, 320), (500, 320), (750, 320), (550, 500)
]
FILE_PATH = "high_score_record.txt"

class FPSWhackMole:
    def __init__(self, root):
        self.root = root
        self.root.title("Python FPS 综合实践 - 动态打地鼠")
        self.root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT + 80}")
        self.root.resizable(False, False)

        # 核心画布
        self.canvas = tk.Canvas(root, width=WINDOW_WIDTH, height=WINDOW_HEIGHT, bg='#2c3e50')
        self.canvas.pack()
        self._init_background()

        # 游戏状态变量
        self.is_active = True
        self.current_score = 0
        self.timer_left = 60
        self.start_timestamp = None
        self.frame_idx = 0
        self.difficulty_lvl = 0
        self.spawn_cooldown = 0
        
        # 统计数据
        self.total_shots = 0
        self.hits = 0
        self.headshots = 0
        self.best_score = self._load_best_score()

        # 实体存储 {body_id: data_dict}
        self.targets = {}
        self.crosshair_elements = []

        # 绑定事件
        self.canvas.bind("<Motion>", self._update_crosshair)
        self.canvas.bind("<Button-1>", self._handle_shoot)
        
        self._setup_ui()
        self._game_tick()
        self._update_timer()

    def _init_background(self):
        # 绘制背景纹理与洞口
        for x, y in HOLE_LOCATIONS:
            self.canvas.create_oval(x-40, y-40, x+40, y+40, fill='#1a1a1a', outline='#95a5a6', width=2)

    def _setup_ui(self):
        self.ui_bar = tk.Frame(self.root)
        self.ui_bar.pack(fill=tk.X)
        
        self.lbl_score = tk.Label(self.ui_bar, text=f"分数: {self.current_score}", font=("Arial", 12))
        self.lbl_score.pack(side=tk.LEFT, padx=10)
        
        self.lbl_timer = tk.Label(self.ui_bar, text=f"时间: {self.timer_left}", font=("Arial", 12))
        self.lbl_timer.pack(side=tk.LEFT, padx=10)

        self.lbl_stats = tk.Label(self.ui_bar, text="命中率: 0% | 爆头率: 0%", font=("Arial", 12))
        self.lbl_stats.pack(side=tk.LEFT, padx=10)

        self.btn_reset = tk.Button(self.ui_bar, text="重置游戏", command=self._reset_engine)
        self.btn_reset.pack(side=tk.RIGHT, padx=15)

    def _create_target(self):
        active_holes = [t['pos'] for t in self.targets.values()]
        free_holes = [h for h in HOLE_LOCATIONS if h not in active_holes]
        
        if not free_holes: return
        
        tx, ty = random.choice(free_holes)
        
        # 身体构造
        body_id = self.canvas.create_rectangle(tx-15, ty-15, tx+15, ty+15, fill='#27ae60', outline='#2ecc71')
        head_id = self.canvas.create_oval(tx-18, ty-40, tx+18, ty-10, fill='#f1c40f', outline='#f39c12')
        hp_text = self.canvas.create_text(tx, ty+30, text="HP: 2", fill='white')

        self.targets[body_id] = {
            'pos': (tx, ty),
            'hp': 2,
            'parts': [body_id, head_id, hp_text],
            'head_center': (tx, ty-25),
            'head_r': 18,
            'birth_frame': self.frame_idx
        }

    def _handle_shoot(self, event):
        if not self.is_active: return
        
        self.total_shots += 1
        mx, my = event.x, event.y
        hit_detected = False

        for bid, data in list(self.targets.items()):
            # 爆头检测
            hx, hy = data['head_center']
            if math.hypot(mx - hx, my - hy) <= data['head_r']:
                self.current_score += 50
                self.headshots += 1
                self.hits += 1
                self._destroy_target(bid)
                hit_detected = True
                break
            
            # 躯干检测
            tx, ty = data['pos']
            if tx-15 <= mx <= tx+15 and ty-15 <= my <= ty+15:
                data['hp'] -= 1
                self.hits += 1
                if data['hp'] <= 0:
                    self.current_score += 20
                    self._destroy_target(bid)
                else:
                    self.canvas.itemconfig(data['parts'][-1], text=f"HP: {data['hp']}")
                hit_detected = True
                break
        
        self._flash_effect(mx, my)
        self._refresh_stats()

    def _destroy_target(self, bid):
        for p in self.targets[bid]['parts']:
            self.canvas.delete(p)
        del self.targets[bid]

    def _flash_effect(self, x, y):
        f = self.canvas.create_oval(x-5, y-5, x+5, y+5, fill='white', outline='yellow')
        self.root.after(80, lambda: self.canvas.delete(f))

    def _update_crosshair(self, event):
        for c in self.crosshair_elements: self.canvas.delete(c)
        cx, cy = event.x, event.y
        l = 15
        self.crosshair_elements = [
            self.canvas.create_line(cx-l, cy, cx+l, cy, fill='red'),
            self.canvas.create_line(cx, cy-l, cx, cy+l, fill='red')
        ]

    def _game_tick(self):
        if self.is_active:
            self.frame_idx += 1
            
            # 难度控制
            spawn_interval = max(20, 60 - self.difficulty_lvl * 10)
            stay_limit = max(30, 90 - self.difficulty_lvl * 15)

            if self.spawn_cooldown <= 0:
                self._create_target()
                self.spawn_cooldown = spawn_interval
            else:
                self.spawn_cooldown -= 1

            for bid, data in list(self.targets.items()):
                if self.frame_idx - data['birth_frame'] > stay_limit:
                    self._destroy_target(bid)

        self.root.after(16, self._game_tick)

    def _update_timer(self):
        if not self.is_active: return
        
        if self.start_timestamp is None: self.start_timestamp = time.time()
        elapsed = int(time.time() - self.start_timestamp)
        self.timer_left = max(0, 60 - elapsed)
        self.difficulty_lvl = elapsed // 20
        
        self.lbl_timer.config(text=f"时间: {self.timer_left}")
        if self.timer_left <= 0:
            self._end_game()
        else:
            self.root.after(1000, self._update_timer)

    def _refresh_stats(self):
        acc = (self.hits / self.total_shots * 100) if self.total_shots > 0 else 0
        hs_rate = (self.headshots / self.hits * 100) if self.hits > 0 else 0
        self.lbl_score.config(text=f"分数: {self.current_score}")
        self.lbl_stats.config(text=f"命中率: {acc:.1f}% | 爆头率: {hs_rate:.1f}%")

    def _end_game(self):
        self.is_active = False
        if self.current_score > self.best_score:
            self._save_best_score(self.current_score)
        self.canvas.create_text(WINDOW_WIDTH//2, WINDOW_HEIGHT//2, 
                                 text=f"最终得分: {self.current_score}\n最高纪录: {self.best_score}", 
                                 fill="white", font=("Arial", 30), justify='center')

    def _reset_engine(self):
        self.is_active = True
        self.current_score = 0
        self.start_timestamp = None
        self.total_shots = 0
        self.hits = 0
        self.headshots = 0
        for bid in list(self.targets.keys()): self._destroy_target(bid)
        self.canvas.delete("all")
        self._init_background()
        self._update_timer()

    def _load_best_score(self):
        if os.path.exists(FILE_PATH):
            with open(FILE_PATH, "r") as f:
                return int(f.read() or 0)
        return 0

    def _save_best_score(self, score):
        with open(FILE_PATH, "w") as f:
            f.write(str(score))

if __name__ == "__main__":
    app_root = tk.Tk()
    engine = FPSWhackMole(app_root)
    app_root.mainloop()

4. 知识总结

  • GUI 开发基础: 掌握了 tkinter 画布组件的使用,包括几何图形绘制、层级管理以及动态属性更新(itemconfig)。
  • 事件驱动编程: 深入理解了鼠标事件绑定与定时任务(after 方法)的协作,实现了流畅的游戏主循环。
  • 数学在编程中的应用: 利用勾股定理(math.hypot)进行精确的圆形碰撞检测,是处理复杂 UI 交互的基础。
  • 软件工程实践: 通过封装类结构管理全局状态,并利用文件 IO 实现了基础的数据持久化。

相关文章

Linux crontab 详解

1) crontab 是什么cron 是 Linux 的定时任务守护进程;crontab 是用来编辑/查看“按时间周期执行命令”的表(cron table)。常见两类:用户 crontab:每个用户一份(crontab -e 编辑)系统级 crontab / cron.d:可指定执行用户(/etc/crontab、/etc/cron.d/*)2) crontab 时间...

富文本里可以允许的 HTML 属性

一、所有标签默认允许的安全属性(极少)class        (可选)id           (通常建议禁用)title️ 注意:id 容易被滥用做锚点注入,很多系统直接禁用class 允许的话最好只允许固定前缀(如 editor-*)二、a 标签允许属性<a href="" t...

Mac 安装 Node.js 指南

方法一:通过官网安装包(最简单,适合初学者)如果你只是想快速安装并开始使用,这是最直接的方法。访问 Node.js 官网。页面会显示两个版本:LTS (Recommended For Most Users):长期支持版,最稳定。建议选这个。Current:最新特性版,包含最新功能但可能不够稳定。下载 .pkg 安装包并运行。按照安装向导点击“下一步”即可完成。方法二:使用 Homebrew 安装(...

Laravel 事件和监听器创建

在 Laravel 中,使用 Artisan 命令创建 Events(事件) 和 Listeners(监听器) 是非常高效的。你可以通过以下几种方式来实现:1. 手动创建单个 Event如果你只想创建一个事件类,可以使用 make:event 命令:Bashphp artisan make:event UserRegistered执行后,文件将生成在 app/Even...

自定义域名解析神器 dnsmasq

什么是 dnsmasq?dnsmasq 是一个轻量级、功能强大的网络服务工具,专为小型和中等规模网络设计。它是一个综合的网络基础设施解决方案[1]。dnsmasq 能做什么?功能说明应用场景DNS 转发与缓存将 DNS 查询转发到上游服务器(ISP、Google DNS 等),并在本地缓存结果加快 DNS 查询速度,减少外部 DNS 流量本地 DNS解析本地网络设备的主机名,无需编辑&n...

linux screen 用法详情 (nohup 的替代方案)

一、screen 是什么?能干嘛?screen 是一个终端复用器,可以:在一个 SSH 会话中开多个“虚拟终端”SSH 断线后,程序仍然在后台运行随时重新连接到原来的会话特别适合:nohup 的替代方案跑脚本 / 爬虫 / 训练模型运维、远程开发二、安装 screen# CentOS / Rocky / Almayum install -y screen# Debian / Ubuntuapt i...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。