基于 tkinter 库的 FPS 射击类打地鼠游戏开发详解
在 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 实现了基础的数据持久化。