项目名称:小鱼爱上钩
版本号: 1.0
作者: [ PASSLINK :锦沐Python]
日期: [2024-8-25]
概述:
本文档旨在详细描述小鱼爱上钩游戏的开发过程、技术架构、功能模块、实现细节以及使用说明,为项目团队成员及未来可能的维护者提供全面的参考。小鱼爱上钩是以钓鱼为核心玩法,融合经典游戏黄金矿工的元素,打造一款轻松有趣、富有挑战性的小游戏。玩家通过精准操作鱼钩,以不同概率捕获各种鱼类,积累分数并提升等级。
项目背景与目标
- 背景: 本项目旨在利用 Python 编程语言及相关的图形库 Pygame 模仿黄金矿工,以面向对象编程思想构建游戏程序,为 Python 初学者提供一定的实践参考价值。
- 目标:
-
- 实现游戏的基本玩法,包括抓取、计分等功能。
- 提供良好的用户界面和音效反馈。
需求分析
- 游戏界面模块:
-
- 主菜单界面:显示游戏标题、开始游戏、继续游戏、设置选项、最高分、最高等级。
- 配置界面:显示标题、难度、音量、光线、语言、返回操作。
- 游戏进行界面:展示鱼群场景、气泡、暂停、等级、得分进度、绳索强度。
- 暂停界面:显示标题、继续游戏、返回操作。
- 对象:
-
- 气泡:装饰作用,模拟水中气泡
- 鱼:作为 NPC,不受玩家控制,随机产生。
- 玩家:控制角色,通过操纵鱼钩行为与鱼群互动获取一定分数。
- 控制对象:
-
- 气泡系统:管理所有气泡位置,大小更新,负责气泡的产生与销毁动作。
- 鱼群系统:管理所有鱼类位置,大小,负责鱼类的产生与销毁动作
- 音效系统:负责加载播放或暂停音频
- 碰撞系统:管理所有对象之间的碰撞逻辑
4.其他模块:
- 配置模块:保存常量,包括资源文件路径,颜色,翻译,字体等信息
界面设计
使用绘图软件 Pixso 绘制游戏的主要界面,并收集图片,音频,字体素材。
页面名称 | 含义 |
start | 开始页面 |
pause | 暂停界面 |
running | 运行界面 |
settings | 设置界面 |
开始界面:
交互按钮包括:继续游戏,开始新游戏,设置
设置:
交互按钮包括:难度数值设置,音量设置,光线设置,语言设置,返回
运行:
交互按钮包括:暂停
暂停:
交互按钮包括:继续游戏,返回开始界面
- 界面实现:使用 Pygame 的绘图功能绘制游戏界面元素,确保主题颜色统一,界面布局合理。
- 逻辑处理: 编写游戏逻辑函数,处理鱼钩的移动、抓取动作,以及碰撞判定逻辑, 主要使用了 math, random 模块。
- 音效实现: 使用 pygame.mixer.Sound 加载和播放音效文件,增强游戏体验。
- 异常处理: 编写异常处理机制,确保游戏在遭遇意外情况时能够稳定运行。
实际运行效果
技术架构
- 编程语言: Python == 3.10.8
- 开发工具:PyCharm 2023
- 依赖库: Pygame == 2.60 Pygame 官方文档
pygame 简介:
Pygame 是一个流行的 Python 库,专为游戏开发设计。它提供了一系列易于使用的功能,包括图形渲染、声音播放、事件处理、时间控制等,让开发者能够轻松创建 2D 游戏和多媒体应用程序。pygame 主要逻辑就是使用 while 循环不断刷新页面,在高速刷新率下使页面产生动画效果。
核心特点
- 图形渲染:Pygame 允许你创建和管理图像(Surface 对象),并在屏幕上绘制它们。你可以加载图片、绘制形状、文本等。
- 声音播放:通过 Pygame 的音频模块,你可以加载和播放声音文件,控制声音的播放速度、音量,以及同时播放多个声音。
- 事件处理:Pygame 提供了一个事件队列,用于接收和处理用户的输入(如键盘按键、鼠标移动和点击)以及系统事件(如窗口关闭)。
- 时间控制:Pygame 的 Clock 对象用于控制游戏的帧率,确保游戏以稳定的速率运行,同时提供方法来延迟执行以匹配帧率。
- 碰撞检测:通过 Rect 对象,Pygame 提供了简单的碰撞检测功能,用于确定两个矩形是否相交。
运行流程
- 初始化:在使用 Pygame 的任何功能之前,你需要初始化 Pygame 模块。
- 创建窗口:设置窗口的大小和标题,并创建一个用于绘图的 Surface 对象。
- 事件循环:通过不断检查事件队列,处理用户的输入和系统事件。
- 游戏逻辑:在事件循环中,根据游戏逻辑更新游戏状态(如玩家位置、分数等)。
- 渲染:将图像、文本等绘制到 Surface 对象上,然后将其渲染到屏幕上。
- 帧率控制:使用 Clock 对象来控制游戏的帧率,确保游戏的流畅性。
- 声音:加载和播放声音效果,增强游戏体验。
代码规范
- 模块名(文件名)使用小写字母与下划线组合,语义清晰,例如 fish_sys.py。
- 常量名,使用大写、数字、下划线组合, 例如 FISH_PATH_1。
- 类名:使用双驼峰命名法,单词开头大写,例如 MainWindow。
- 素材文件命名:小写字母、下划线、数字组合,相关资源前缀一致,例如 fish_1.png, bgm_1。
- 变量名:小写字母、下划线、数字组合,相关变量前缀一致,例如 start_position。
程序原理
程序读取了配置常量后,会创建一个窗口,窗口内包含一个根 Surface 对象,本项目将他命名为 screen。之后会启动一个 While 循环,每次循环将做以下事:
- 进行事件检测,如有事件变更就会触发函数修改某些变量,这些变量中有部分直接会影响绘图结果。
- 清空之前绘制的所有图像,将 screen 填充全黑。
- 然后对象 ControlSys 汇聚所有页面控制对象,重新根据新的变量值绘制每个图层对象。这其中会有一些图层变化,比如颜色,位置,大小等。
- 刷新屏幕 screen,将绘制好的对象按照一定的绘制顺序显示到 screen 上。
- 在极短时间内,不断重复 1 - 4 步骤,最终可以在人的视觉和脑海里产生动态效果。
编码:
经过需求分析,详细的界面设计后,就可以开始搭建开发环境进行编码了。编码前,你需要知道一些关于项目的细节。
主窗口坐标系:
观察下图,其中白色区域为你得电脑屏幕,红色区域为游戏界面窗口,在计算机里坐标起点均以左上角,水平向右方向为X轴,竖直向下方向为Y轴。在编写代码时,你应当把游戏窗口当作一张画布,通过编程得方法将各种图形元素加载到画布上,然后通过更新位置得方式让他们动起来。请注意,在 pygame 主循环内,每次循环更新一次画面,也就是播放一帧,你要实现动画就应当保存一个变量用来更新图形元素得位置。
面向对象编程:
什么是对象,我的理解:对象是一个整体,该整体包含一些属性,以及一些自身方法。如何定义对象,使用 class 关键字创建,你可能会疑惑 self 参数,其实它是固定写法,它指向对象本身,在对象内部提供自身所有定义好的变量或者函数, 通过该关键字创建的变量在本对象销毁时才会销毁。你可简单理解为对象里的全局访问接口,通过它获取对象本身所有细节。
面向对象编程其实就是将多个对象组合到一起完成一件事或一个程序。
我们以 玩家 Player 实例为例:
实例主要完成了参数初始化,一般写在魔法函数 __init__
内,变量即属性。方法就是自定义的 move, attack 函数。
在某些对象里你还会遇到 __new__
函数,该函数在创建实例时自动触发,更多细节可以参考我的小红书笔记。
class Player():
def __init__(self, name, health, attack_power):
self.name = name # 玩家的名字
self.health = health # 玩家的生命值
self.attack_power = attack_power # 玩家的攻击力
def move(self, direction):
# 玩家向指定方向移动
print(f"{self.name} is moving {direction}.")
def attack(self, target):
# 玩家攻击指定目标
print(f"{self.name} attacks {target} with {self.attack_power} damage.")
# 创建玩家实例, 传入参数将触发魔法函数 __init__
player1 = Player("Knight", 100, 15)
player2 = Player("Wizard", 80, 20)
# 使用玩家的方法
player1.move("north") # 玩家向北方移动
player2.attack(player1) # 巫师攻击骑士
项目结构
我们要实现一个完整的程序,就要按照一个合理的结构编写代码:
- 编写 config 模块(文件),用于保存一些常量,比如文件路径,颜色值等。
- 编写 MainWindow 类,创建主窗口 screen, 主循环,其中主循环内部需要填充画面更新逻辑。
- 编写 ControlSys 类 ,该类负责四个界面的绘制,包含菜单按钮,背景图片,文字等, 该类还负责组合下面的对象。
- 编写 MusicSys 类,用于控制整个程序内的音效。
- 编写 Bubble 类,定义装饰气泡,本质就是一个圆圈。BubblesSys 类管理一个气泡列表的创建,更新,删除等。
- 编写 Fish 类,定义鱼对象,本质就是管理鱼的图片大小,位置等。FishSys 类管理一个鱼列表的创建,更新,删除等。
- 编写 PlayerSys 类,定义玩家,本质就是控制图片,直线属性。
- 编写 CrashSys 类,用于管理各种对象之间的碰撞逻辑
│ main.py # 入口文件
│ README.md # 说明文档
│ requirements.txt # 依赖表
│ __init__.py
├─assets
│ ├─fonts # 字体文件
│ │ Alibaba-PuHuiTi-Regular.ttf
│ ├─images # 图片文件
│ │ bg.png
│ │ fishhook.png
│ │ fish_1.png
│ │ fish_2.png
│ │ fish_3.png
│ │
│ └─sounds # 音频文件
│ bgm1.mp3
│ bgm2.mp3
├─src # 源码文件
│ │ main_window.py # 主窗口
│ │
│ ├─config
│ │ │ config.py # 配置模块
│ │
│ ├─sys # 控制对象
│ │ bubbles_sys.py
│ │ control_sys.py
│ │ crash_sys.py
│ │ fish_sys.py
│ │ music_sys.py
│ │ player_sys.py
config 配置模块
在编写代码时,无法避免引入一些图片,音乐,字体资源,而这些资源都需要文件路径进行访问,为了统一管理我将所有常量字符串放到了 config 模块。本项目使用了相对路径与工作路径拼接法,其中相对路径以 Fishing 文件夹为根目录,工作路径则是以 main.py 运行路径为准,在不同的设备上是不同的。你可以简单将工作路径理解为一个字符串变量,不同设备不同文件夹位置启动项目获取的路径不同。
- 绝对路径 (Absolute Path):
-
- 绝对路径是从文件系统的根目录开始的完整路径。
- 它提供了文件或目录在文件系统中的确切位置。
- 绝对路径在不同操作系统中可能有不同的表示方法,例如,在Windows系统中通常以盘符开头,如
C:\Users\Username\Documents
,在Unix/Linux系统中通常以根目录/
开始,如/home/username/Documents
。
- 相对路径 (Relative Path):
-
- 相对路径是相对于当前工作目录的路径。
- 它不从根目录开始,而是从当前位置开始计算。
- 例如,如果你当前在
C:\Users\Username\Documents
目录下,那么相对路径.\file.txt
就指的是C:\Users\Username\Documents/file.txt
。
- 工作路径 (Working Directory):
-
- 工作路径,也称为当前目录或当前工作目录,是操作系统当前正在使用的目录。
- 当你打开一个命令行界面时,它通常会有一个默认的工作路径,你可以在这个路径下执行命令。
- 每次运行 main.py 都会在终端输入以下类似命令:D:\PyGamePro\venv\Scripts\python.exe D:\PyGamePro\Fishing\main.py
# pathlib 是 Python 3.4 引入的一个标准库,它提供了面向对象的文件系统路径操作。
from pathlib import Path
import pygame
# 初始化字体配置,不可少
pygame.font.init()
# 标题
WIN_TITLE = "小鱼爱上钩"
# 窗口大小
SCREEN_WIDTH = 900
SCREEN_HEIGHT = 500
# 每秒刷新帧数
FRASH_SPEED = 60
# 当前工作目录,以 main.py 为准
_current_path = Path(".").resolve()
# 字体
FONT_PATH = _current_path / "assets" / "fonts" / "Alibaba-PuHuiTi-Regular.ttf"
# 背景
BACKGROUND_IMG_PATH = _current_path / "assets" / "images" / "bg.png"
PUAS_PATH = _current_path / "assets" / "images" / "puas.png"
# 鱼钩
FISH_HOOK_PATH = _current_path / "assets" / "images" / "fishhook.png"
# 鱼
FISH_PATH_1 = _current_path / "assets" / "images" / "fish_1.png"
# 背景音乐
BGM_PATH_1 = _current_path / "assets" / "sounds" / "bgm1.mp3"
# 颜色
BUTTON_COLOR = (247, 181, 0)
# 语言
WORDS_DICT = {
"小鱼爱上钩": "Fish Love Hook",
"最高分": "Highest marks",
"最高等级": "Top level",
"继续游戏": "Continue",
"开始新游戏": "Start",
"设置": "Settings",
"难度": "Diff",
"音量": "Vol",
"亮度": "Illumi",
"语言": "Lang",
"返回": "Home",
"暂停": "Pause",
"绳索强度":"Rope Strength",
"等级LV":"Level"
}
主窗口 MainWindow
要启动一个游戏,我们必须创建一个窗口,你可以将他理解为一个根容器。该容器容纳程序所有可视化部件,比如图片,按钮等。从以下代码,你可以观察到我们配置了窗口基础参数,例如标题,图标,窗口大小等,其中 running_flag 用于控制循环的状态,screen 是一个 Surface 对象,本项目的可视化部件都将绘制在它上面。其次我们定义了一个 run_game 函数,该函数包含了一个循环,循环内部主要检测退出事件,鼠标事件,以及页面的更新。此时,你可以创建一个 MainWindow 实例,并运行 run_game 函数,启动程序,你将得到一个无内容的长方形窗口。
import pygame
from Fishing.src.config.config import SCREEN_WIDTH, SCREEN_HEIGHT, ICON_PATH
# 必须初始化
pygame.init()
class MainWindow():
"""
负责主窗口初始化,加载控制系统,刷新页面,检测点击事件
"""
def __init__(self):
# 配置主窗口大小
self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
# 窗口标题
pygame.display.set_caption(WIN_TITLE)
# 图标
icon = pygame.image.load(ICON_PATH).convert_alpha()
pygame.display.set_icon(icon)
# 用于控制刷新帧率
self.clock = pygame.time.Clock()
# 运行标志
self.running_flag = True
# 初始化画面控制器
self.control_sys = ControlSys(self.screen)
def run_game(self):
# 循环处理页面
while self.running_flag:
# 配置刷新率为 60 帧
self.clock.tick(FRASH_SPEED)
# 事件处理
for event in pygame.event.get():
# 退出程序事件
if event.type == pygame.QUIT:
self.running_flag = False
# 关闭背景音乐
music_sys.play_bgm(False)
# 鼠标事件
elif event.type == pygame.MOUSEBUTTONDOWN:
# 检测左键点击
if event.button == pygame.BUTTON_LEFT:
pass
# 清空画面
self.screen.fill(BLACK_COLOR)
# 刷新页面
pygame.display.flip()
# 退出程序
pygame.quit()
页面控制器 ControlSys
主窗口 MainWindow 创建完成后,我们就可以在 screen 上绘制图像了,screen 是根容器。在此之前,你应当知道 pygame 的 while 循环本质上做了什么。观察下图,本项目绘图结构是图层结构,screen 就是我们的主窗口图层,bg 是背景图层, other 是游戏界面和其他游戏元素图层。
while 内部主要做了这些事:事件检测(本质上就是检查特殊变量值),清空画面 screen 内容,填充背景,游戏界面等元素到 screen 上,最后触发窗口重绘 screen。将所有图层一起显示,你就可以看到完整的画面了。当然绘制的顺序有讲究,比如背景图层应当放到底层,否则会覆盖其他图层。覆盖不是完全的覆盖,主要根据绘制元素的大小决定,绘制元素周围默认都是透明的。
为了便于管理页面更新,我将页面更新都放到了 ControlSys 内部,在主窗口我只需引入一个页面更新函数即可。
该类开始涉及 pygame 里的绘制对象属性及方法,你需要关注以下:
- Surface:
-
Surface
是 Pygame 中用于表示图像的类。你可以创建一个空白的Surface
对象,然后在上面绘制像素。- 常用方法:
-
-
blit(source, dest)
:将一个Surface
对象或Image
对象绘制到另一个Surface
上。fill(color)
:用指定的颜色填充整个Surface
。set_alpha(128)
: 指定透明度
-
-
- 常用属性:
-
-
get_width()
和get_height()
:获取Surface
的宽度和高度。get_rect()
:返回一个与Surface
大小相同的Rect
对象。
-
- Font:
-
Font
类用于加载和渲染文本。你可以指定字体文件和字体大小来创建Font
对象。- 常用方法:
-
-
render(text, antialiasing, color, background=None)
:渲染文本,返回一个包含渲染文本的Surface
对象。
-
- Rect:
-
Rect
是 Pygame 中表示矩形区域的类,常用于碰撞检测和布局管理。- 常用方法:
-
-
colliderect(other)
:检查两个矩形是否碰撞。contains(pt)
:检查点是否在矩形内。
-
-
- 常用属性:
-
-
x
,y
:矩形左上角的坐标。width
,height
:矩形的宽度和高度。top
,topleft
,bottom,
left,
right`:矩形的边界坐标。
-
- pygame.draw Pygame 库中用于在
Surface
上绘制基本图形的模块。它提供了一系列的函数来绘制线、矩形、圆形等。以下是本项目常用的pygame.draw
函数: - 绘制圆:
-
pygame.draw.circle(surface, color, center, radius)
:在surface
上绘制一个圆,center
是圆心坐标,radius
是半径。pygame.draw.ellipse(surface, color, rect)
:在surface
上绘制一个椭圆,rect
是一个Rect
对象,定义了椭圆的边界框。
- 绘制线:
-
pygame.draw.line(surface, color, start_pos, end_pos, width=1)
:在surface
上绘制一条线,从start_pos
到end_pos
,width
是线的宽度。
- 绘制矩形:
-
pygame.draw.rect(surface, color, rect, width=0)
:在surface
上绘制一个矩形,rect
是一个Rect
对象,定义了矩形的位置和大小,width
是矩形边框的宽度,如果为 0,则填充整个矩形。pygame.draw.rect(surface, color, rect, cornersize)
:绘制一个带有圆角的矩形。
使用上述的对象属性及方法,我们可以创建一个开始界面,为了方便表达省略了一些值的计算以及变量,你可以参考源码。
你需要关注的变量 :screen 主窗口传递的根容器,current_page 当前页面状态,用于控制页面显示。在 _start_init
函数内,我们只进行了创建和初始化对象,具体创建了一个 Rect 对象, 由于该对象便于碰撞检测,项目内所有按钮都是 Rect 。_start_init
函数在对象初始化时执行一次。为什么要执行该函数?该函数定义了最基本的变量,比如位置,大小,对象等信息,在函数 _start_ui
中或其他地方会被引用赋值,从而改变页面状态。
碰撞检测本质上就是判断某些点是否包含在规定 Rect 像素范围,如果包含就为真,否则为假。
class ControlSys():
def __init__(self, screen=None ):
self.screen = screen
# 当前页面
self.current_page = "start"
# 初始化参数
self._start_init()
def _start_init(self):
"""初始化变量"""
# 开始新游戏
self.start_new_button = pygame.Rect(self.start_menu_new_button_pos_x,
self.start_menu_new_button_pos_y,
self.start_menu_new_button_width,
self.start_menu_new_button_height)
pass
初始化对象后,我们就可以在 _start_ui 函数内引用他们,用于绘制图形。该函数会被经常触发,主要方式为点击事件。
def _start_ui(self):
"""绘制页面"""
# 菜单栏背景
menu_bar = pygame.Surface((self.menu_width, self.menu_height))
menu_bar.set_alpha(128)
menu_bar.fill(MEAU_COLOR)
self.screen.blit(menu_bar, (self.menu_pos_x, self.menu_pos_y))
# 按钮
pygame.draw.rect(self.screen, BUTTON_COLOR, self.start_new_button, border_radius=20)
# 文字
# 标题
title = FONT_SIZE_36.render(self.translated("小鱼爱上钩"), True, TILTE_COLOR)
title_rect = title.get_rect()
title_rect.center = (self.start_menu_title_pos_x,
self.start_menu_title_pos_y)
self.screen.blit(title, title_rect)
函数 draw_bg
用于绘制背景图片,draw_bg
会被频繁调用。函数 draw_fog ,使用 alpha 变量控制亮度。 alpha 在 0 到 255 的范围,值越大越透明。
def draw_bg(self):
# 背景图片
background_image = pygame.image.load(BACKGROUND_IMG_PATH).convert()
background_image = pygame.transform.smoothscale(background_image,
(self.screen_width, self.screen_height))
self.screen.blit(background_image, (0, 0))
def draw_fog(self):
# 创建一个与原图像大小相同的全白色表面
fog_surface = pygame.Surface(self.screen.get_size())
fog_surface.fill((0,0,0))
# 设置透明度
fog_surface.set_alpha(self.alpha)
self.screen.blit(fog_surface, (0, 0))
函数 draw_ui
用于组装所有游戏页面,从开始到结尾逐一绘制图层,你可以看到此函数使用 current_page
变量控制相应图层绘制。
def draw_ui(self):
"""
绘制相应页面
:param page: start settings pause running
"""
# 绘制背景
self.draw_bg()
# 其他游戏图层
# 绘制遮罩层调节亮度
self.draw_fog()
if self.current_page == "start":
self._start_ui()
elif self.current_page == "settings":
self._settings_ui()
pass
你可能在源代码里看到了,我们在 while 循环调用了它,并且以 60fps 调用它不断重新绘制图层,如此高的更新速度下,你几乎察觉不到卡顿。
def run_game(self):
# 循环处理页面
while self.running_flag:
# 配置刷新率为 60 帧
self.clock.tick(60)
# 其他 .......
# 清空画面
self.screen.fill((0, 0, 0))
# 更新页面
self.control_sys.draw_ui()
# 刷新页面
pygame.display.flip()
接下来展示如何检测按钮是否被点击,在函数 click_event 内,我们做了大量相似的判断操作,先判断当前页面状态,然后判断 pos 坐标是否包含在按钮范围内,然后进行变量的改变。
def click_event(self, pos):
"""
点击事件处理
:param pos: 当前鼠标坐标,当前页面
:param page:
"""
if self.current_page == "start":
if self.start_continue_button.collidepoint(pos):
self.current_page = "running"
elif self.start_new_button.collidepoint(pos):
self.current_page = "running"
elif self.start_settings_button.collidepoint(pos):
self.current_page = "settings"
pass
你可以看到 click_event 也在 while 循环内执行,传入的 pos 为鼠标左键点击时获取的坐标(x, y):
def run_game(self):
# 循环处理页面
while self.running_flag:
# 配置刷新率为 60 帧
self.clock.tick(60)
# 事件处理
for event in pygame.event.get():
# 退出程序事件
if event.type == pygame.QUIT:
self.running_flag = False
# 鼠标事件
elif event.type == pygame.MOUSEBUTTONDOWN:
# 检测左键点击
if event.button == pygame.BUTTON_LEFT:
self.control_sys.click_event(pos=event.pos)
文本翻译是如何实现的呢?逻辑很简单,使用了字典通过键访问值的特性。我们可以根据 language
变量来判断是否返回对应的英文。language
变量在点击事件中被改变,具体参考源码。
WORDS_DICT = {
"小鱼爱上钩": "Fish Love Hook",
"最高分": "Highest marks",
"最高等级": "Top level",
"继续游戏": "Continue",
"开始新游戏": "Start",
"设置": "Settings",
"难度": "Diff",
"音量": "Vol",
"亮度": "Illumi",
"语言": "Lang",
"返回": "Home",
"暂停": "Pause",
"绳索强度":"Rope Strength",
"等级LV":"Level"
}
def translated(self, content):
"""
翻译
:param content: 原始内容
:return: 翻译结果
"""
if self.language == "zh":
return content
elif self.language == "en":
return WORDS_DICT[content]
# 使用
settings_font = FONT_SIZE_24.render(self.translated("设置") , True, FONT_COLOR)
音效控制器 MusicSys
哈吉咪~,曼波 ~ 曼波 ~ 。没有音效的游戏会有些枯燥,现在我们把音效添加进来吧。音效有播放,暂停功能,pygame 为我们准备好了音效对象 Sound。值得注意的是,pygame.mixer.init()
必须初始化,MusicSys 是单例模式,单例模式就是全局无论创建多少个 MusicSys 对象,使用的都是第一次被创建的对象,该对象保存在 _instance
变量里。单例模式具体细节可参考我的小红书笔记。
函数 play_bgm 创建了一个 Sound 对象,使用 random.choice
函数随机的在 bg_list 中挑选一首音乐文件路径,然后根据 flag 标志选择 播放 或 暂停 音乐。
# -*- coding: utf-8 -*-
import random
import pygame
# 必须初始化
pygame.mixer.init()
from Fishing.src.config.config import BGM_PATH_1, BGM_PATH_2, BGM_PATH_3
class MusicSys():
_instance = None
def __init__(self):
self.bg_list = [BGM_PATH_1, BGM_PATH_2, BGM_PATH_3]
self.bg_music = pygame.mixer.Sound( random.choice(self.bg_list))
# 单例模式
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def play_bgm(self, flag:bool):
self.bg_music = pygame.mixer.Sound(random.choice(self.bg_list))
# 背景音乐
if flag:
self.bg_music.play(-1)
else:
self.bg_music.stop()
def volume_setting(self, volume):
# 音量控制
self.bg_music.set_volume(volume)
music_sys = MusicSys()
在其他地方使用,导入 music_sys 对象,并引用对应的方法即可:
from Fishing.src.sys.music_sys import music_sys
def run_game(self):
# 播放背景音乐
music_sys.play_bgm(True)
# 循环处理页面
while self.running_flag:
# 配置刷新率为 60 帧
self.clock.tick(60)
# 事件处理
for event in pygame.event.get():
# 退出程序事件
if event.type == pygame.QUIT:
self.running_flag = False
# 关闭背景音乐
music_sys.play_bgm(False)
气泡系统 BubblesSys
只有鱼游动的页面太枯燥,我们给他加个气泡吧。气泡是一个圆形,创建时输入不同的半径绘制不同大小的圆形。我们先定义气泡对象,气泡对象大小,起点,方向,步长随机产生。
class Bubble():
def __init__(self, start_radius, start_center_pos):
# 注意 参数 start_radius 是指释放气泡的范围半径
# 气泡半径
self.radius = random.randint(3, start_radius // 4)
pos_x = random.randint(start_center_pos[0] - start_radius, start_center_pos[0] + start_radius)
pos_y = random.randint( start_center_pos[1]+self.radius,start_center_pos[1]+2*self.radius)
# 气泡移动方向向量
self.direct = pygame.math.Vector2(pos_x, pos_y)
# 气泡范围
self.rect = pygame.Rect(pos_x - self.radius, pos_y - self.radius, self.radius * 2, self.radius * 2)
# 气泡步长,用于控制速度
self.step = pygame.math.Vector2(random.randint(1, 4), random.randint(1, 3))
我们的气泡系统运行方式如下,气泡从底部创建,然后沿着 direct 向量方向作直线运动,每帧移动距离 step。超出视野,也就是左,上,右三边后,进行气泡销毁操纵,然后重新创建气泡。
气泡对象保存到 bubbles_list
列表,函数 create_bubble
用于创建新的气泡对象存入气泡列表。函数 crash_window
用于销毁气泡,函数 update
用于更新气泡位置信息。
class BubblesSys():
def __init__(self, screen=None, max_num=10, start_center_pos=(600, 400), start_radius=300):
self.screen = screen
# 气泡数量
self.max_num = max_num
# 释放气泡的中心位置
self.start_center_pos = start_center_pos
# 释放气泡距离中心位置的距离
self.start_radius = start_radius
# 气泡位置信息
self.bubbles_list = [self.create_bubble() for _ in range(random.randint(3, self.max_num)) ]
def create_bubble(self):
# 创建新的气泡
# music_sys.play_bubble(True)
bubble = Bubble(self.start_radius, self.start_center_pos)
return bubble
def crash_window(self, bubble):
# 检查气泡是否超出窗口,超出销毁
bubble_rect = bubble.rect
window_rect = self.screen.get_rect()
if bubble_rect.right < -self.start_radius//2 \
or bubble_rect.left > window_rect.width \
or bubble_rect.bottom < -self.start_radius//2:
# print(bubble)
self.bubbles_list.remove(bubble)
def update(self):
# 判断释放达到数量上线
if len(self.bubbles_list) < self.max_num:
self.bubbles_list.append(self.create_bubble())
# 更新所有气泡位置信息
for bubble in self.bubbles_list:
self.crash_window(bubble)
pygame.draw.circle(self.screen, (255, 255, 255), bubble.rect.center, bubble.radius, 1)
bubble.direct = bubble.direct - bubble.step
bubble.rect.center = (bubble.direct.x, bubble.direct.y)
现在,我们只需在 ControlSys 的绘制函数 draw_ui
引用 BubblesSys 对象即可让画面绘制移动的气泡:
def draw_ui(self):
"""
绘制相应页面
:param page: start settings pause running
"""
# 绘制背景
self.draw_bg()
# 动态更新
self.bubble_sys.update()
# 其他。。。。。
鱼群系统 FishSys
鱼群是玩家针对的对象, 玩家通过一定的操纵与鱼交互。为了便于管理,我们创建了 鱼对象 Fish,并使用 FishSys 对所有鱼对象进行管理。
Fish
鱼对象的创建,我们的鱼在画面上是一张图片,我们要做的事情就是控制该图片如何移动,变换。你需要关注的变量:now_position
position_index
position_list
: 鱼的位置rect
: 碰撞
class Fish():
def __init__(self, name="", img_url="", screen=None, min_eacape_probaility=0.5):
self.screen = screen
# 鱼类名称
self.name = name
# 鱼的大小
self.size = (random.randint(40, 100), random.randint(80, 120))
# 携带的分数
self.score = random.randint(10, self.size[0]*self.size[1])
# 当前位置
self.now_position = []
# 鱼的位置
self.position_index = 0
# 鱼的位置序列 20个
self.position_list = []
# 水平翻转图像标志
self.flip_flag = True
self._create_fish_move_pos_list()
# 鱼的力量
self.strength = 10
# 成功逃脱概率
self.eacape_probaility = random.uniform(min_eacape_probaility, 1)
# 是否被玩家捕获
self.catch_flag = False
# 鱼的图片
original_surface = pygame.image.load(img_url).convert_alpha()
surface = pygame.transform.smoothscale(original_surface, self.size)
self.surface = pygame.transform.flip(surface, self.flip_flag, False)
# Rect 碰撞范围对象
self.rect = self.surface.get_rect()
其中移动的路线是创建时创建好的,我将鱼的移动路径简化为正弦函数片段。在鱼对象初始化时会执行下面的函数,_create_fish_move_pos_list
函数首先会生成一个周期的正弦函数序列,随机切割一段函数序列,将他缩放到 1*1 的范围内,再放大到与屏幕大小相适应的坐标中,最后以 0.5 的概率反转游动方向。由于随机性,我们每次获取的路径都不同,所以你可以观察到每条鱼的行走路径都有些区别。
def _create_fish_move_pos_list(self):
# 生成一个随机连续位置列表
step = random.randint(500, 900)
x_list = [i * (2 * math.pi / step) for i in range(step)]
sin_list = [(math.sin(x) + 1) / 2 for x in x_list]
# 随机选择数据切片
start = random.randint(0, step // 4)
stop = random.randint(start + 100, step) # 确保 stop 大于 start
# 切片数据
x_slice = x_list[start:stop]
sin_slice = sin_list[start:stop]
# 缩放到 900x600 画布
canvas_width, canvas_height = 900, 600
min_x, max_x = min(x_slice), max(x_slice)
slice_width = max_x - min_x
scaled_x = [(x - min_x) / slice_width * canvas_width for x in x_slice]
scaled_y = [(y - 0.5) * canvas_height for y in sin_slice]
# 组合成对元组
self.position_list = list(zip(scaled_x, scaled_y))
if random.randint(0, 1) == 0:
self.position_list.reverse()
self.flip_flag = False
self.now_position = self.position_list[0]
初始化所有工作后,我们就可以直接遍历位置列表 position_list
绘制鱼的位置了。
def update(self):
if self.catch_flag is False:
# 位置更新
self.now_position = [self.position_list[self.position_index][0],
self.position_list[self.position_index][1]]
self.position_index = (self.position_index + 1) % len(self.position_list)
self.screen.blit(self.surface, self.now_position)
self.rect = self.surface.get_rect()
self.rect.topleft = self.now_position
# 绘制 rect 边界
pygame.draw.rect(self.screen, "red", self.rect, 2)
FishSys
现在我们将对鱼群进行管理,包括鱼的创建,销毁,碰撞检测等。
class FishSys():
def __init__(self, screen=None):
self.screen = screen
# 所有鱼
self.fishs = []
# 最小被捕获概率
self.min_eacape_probaility = 0.1
# 被捕鱼对象
self.catch_fish = None
# 鱼图片列表
self.imgs = [FISH_PATH_1,FISH_PATH_2,FISH_PATH_3,FISH_PATH_4,
FISH_PATH_5,FISH_PATH_6,FISH_PATH_7,FISH_PATH_8]
# 初始获取 10 条鱼
for _ in range(10):
self.create_fish()
def create_fish(self):
"""
创建鱼
"""
fish = Fish(name=datetime.now().strftime("%y%m%d%H%M%S"),
img_url=random.choice(self.imgs), screen=self.screen,
min_eacape_probaility=self.min_eacape_probaility)
print(fish.eacape_probaility)
self.fishs.append(fish)
def update(self):
"""
刷新
"""
for fish in self.fishs:
fish.update()
self.crash_window(fish)
def crash_window(self, fish):
"""
检查鱼是否超出窗口,超出则销毁
:param fish:
"""
fish_rect = fish.rect
window_rect = self.screen.get_rect()
if fish_rect.right < -fish.size[0] \
or fish_rect.left > window_rect.width \
or fish_rect.bottom < -fish.size[1]:
self.fishs.remove(fish)
# 创建新的鱼
self.create_fish()
玩家 Player
原理图如下,玩家初始位置位于窗口横坐标中点位置,其中玩家初始方向为 -90 度,延 X 轴方向为 0 度,逆时针为正方向。玩家动画轨迹为直线一个来回,伸出去再收回来。每次伸出去之前会计算好来回路径上的坐标点序列,以以及鱼钩图片的旋转角度。
玩家是你,通过鼠标事件控制一个四叉鱼钩图片以及一条白色的线。玩家触发点击事件后程序主要做了以下事情:
- 获取玩家在窗口内点击的终点位置 pos(x, y),生成起点到终点位置间直线上的若干点
animate_pos_list
,然后对animate_pos_list
列表元素位置取反。将两个序列合到一起就获得了动画来回路径上的点。然后计算鱼钩图片的旋转角度,根据起点终点坐标计算atan2
结果,经过数学上的逻辑转换,重新绘制鱼钩图片。 - 开启动画沿着 animate_pos_list 坐标序列做直线运动(先来后回),动画期间玩家不再处理新的点击事件,直到玩家动画结束回归起点。
- 每次移动一个点都进行碰撞检测(鱼与鱼钩)
- 计算分值等参数
玩家是单例模式,整个程序只允许一个玩家存在,当然,你也可以对其进行修改。你可能需要关注的变量有:
start_position
, now_position
,target_position
, animate_pos_list
, animate_index
: 位置处理
angle_deg
:鱼钩旋转角度
rect
: 碰撞处理
class PlayerSys():
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, screen=None, (SCREEN_WIDTH//2, 10)):
self.screen = screen
# 开始位置
self.start_position = start_position
# 当前位置
self.now_position = [start_position[0], start_position[1]+10]
# 目标位置
self.target_position = [start_position[0], start_position[1]+10]
# 鱼钩
self.fishhook = pygame.image.load(FISH_HOOK_PATH).convert_alpha()
self.rect = self.fishhook.get_rect()
# 绳索宽度
self.strength = 2
# 等级
self.level = 1
# 分数
self.score = 0
# 最高等级
self.max_level = 1
# 最高分
self.max_score = 1
self.size = [50, 50]
# 动画
self.num_points = 30
# 动画结束标志
self.animate_flag = False
# 动画位置列表
self.animate_pos_list = []
self.animate_index = 0
# 方向
self.angle_deg = 0
# 碰撞
self.catch_flag = False
函数 set_animate_pos
在鼠标点击事件中触发,用于计算玩家动画路径位置序列,并初始化鱼钩图片。
def set_animate_pos(self, pos):
# 配置目标位置
self.target_position[0] = pos[0]
self.target_position[1] = pos[1]
# 防止多次触发
# 生成动画帧玩家位置
if self.animate_flag is False:
for i in range(self.num_points):
t = i / (self.num_points - 1)
x = int(self.start_position[0] + (self.target_position[0] - self.start_position[0]) * t)
y = int(self.start_position[1] + (self.target_position[1] - self.start_position[1]) * t)
self.animate_pos_list.append((x, y))
reversed_list = list(reversed(self.animate_pos_list))
self.animate_pos_list = self.animate_pos_list + reversed_list
# 计算角度(弧度制)
angle_rad = math.atan2(self.target_position[1] - self.start_position[1],
self.target_position[0] - self.start_position[0])
# 将弧度转换为角度
self.angle_deg = 90 - int(math.degrees(angle_rad) % 180)
# 启用动画
self.animate_flag = True
玩家的位置更新函数,在 ControlSys
中的 draw_ui
中被引用。
def update(self):
self.max_score = max(self.max_score, self.score)
self.max_level = max(self.max_level, self.level)
# 开启动画
if self.animate_flag:
self.now_position = self.animate_pos_list[self.animate_index]
if self.catch_flag:
self.animate_index -= 1
else:
self.animate_index += 1
# 动画结束
if self.animate_index == 2*self.num_points - 1 or self.animate_index < 0:
self.animate_flag = False
self.animate_index = 0
self.animate_pos_list.clear()
# 鱼钩角度
surface = pygame.transform.smoothscale(self.fishhook, self.size)
surface = pygame.transform.rotate(surface, self.angle_deg)
self.rect = surface.get_rect()
self.rect.center = self.now_position
self.screen.blit(surface, self.rect.topleft)
pygame.draw.line(self.screen, (255, 255, 255), self.start_position,
self.now_position, self.strength)
# 绘制碰撞范围
pygame.draw.rect(self.screen, "red", self.rect, 2)
开始新的游戏会重置当前玩家获取的分数:
def start_new(self):
self.level = 1
self.score = 0
self.strength = 1
碰撞系统 CrashSys
碰撞系统管理了不同对象之间的碰撞逻辑,原理就是 Rect 对象之间是否有交集的判断。
鱼与玩家碰撞:
观察下面玩家捕捉到鱼对象的图片,你可以清楚的看到玩家的鱼钩与鱼有交叉部分,交叉代表二者发生了碰撞。
关于碰撞处理,只在伸出动画路径途中处理与鱼的碰撞事件,本质上就是判断鱼钩与鱼的 Rect 对象是否交叉。如果碰撞就把鱼的被捕状态置为真,然后鱼之后的位置均为鱼钩的位置。这就完成了鱼对鱼钩的跟随动画,回到玩家起点后鱼被销毁,并计算分值等信息。碰撞检测逻辑直接对 FishSys, PlayerSys 对象的变量进行了修改,这种方式太过于粗鲁,你可以构建新的 get\set函数进行优化。但为了减少函数数量的干扰,我选择直接对其变量进行操纵。
class CrashSys():
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def crash_player_fish(self, fish_sys: FishSys, player_sys: PlayerSys):
"""
玩家与鱼的碰撞检测
:param fish_sys:
:param player_sys:
:return:
"""
# 检测碰撞的第一个
if player_sys.catch_flag:
# 被捕捉的鱼跟随玩家
fish_sys.catch_fish.now_position = player_sys.rect.topleft
# 判断玩家是否回到起点
if player_sys.animate_flag is False:
fish_sys.fishs.remove(fish_sys.catch_fish)
fish_sys.create_fish()
# 解除碰撞状态
player_sys.catch_flag = False
# 计分
player_sys.score += fish_sys.catch_fish.score
player_sys.level = player_sys.score // 100
# 清除
fish_sys.catch_fish = None
return
# 收回间隙不再检测碰撞
if player_sys.animate_index > len(player_sys.animate_pos_list) // 2:
return
# 碰撞检测
for fish in fish_sys.fishs:
if fish.rect.colliderect(player_sys.rect):
# 判断是否可逃脱
if fish.eacape_probaility <= 0.5:
# 碰撞标志位
fish.catch_flag = True
player_sys.catch_flag = True
fish_sys.catch_fish = fish
music_sys.play_fish(True)
break
# 降低被捕概率
fish.eacape_probaility = max(0.1, fish.eacape_probaility - 0.001)
crash_sys = CrashSys()
游戏积分
游戏积分变量在 PlayerSys 对象内部:
# 等级 , 获取 100 分就升 1 个等级
self.level = 1
# 分数
self.score = 0
# 最高等级
self.max_level = 1
# 最高分
self.max_score = 1
其中 level ,score 变量会在碰撞系统中改变。每条鱼生成时都会随机产生一个自带分数和一个被捕概率。碰撞系统会检测被捕概率是否大于 0.5,若大于 0.5 则进行积分操纵。
在 ControlSys 中组装
现在我们需要将所有更新函数组装到页面控制对象中。
def draw_ui(self):
"""
根据 current_page 绘制相应页面
"""
# 绘制背景
self.draw_bg()
# 动态更新
self.bubble_sys.update()
self.fish_sys.update()
# 碰撞系统
if self.current_page == "running":
self.player_sys.update()
crash_sys.crash_player_fish(self.fish_sys, self.player_sys)
# 绘制遮罩层调节亮度
self.draw_fog()
if self.current_page == "start":
self._start_ui()
elif self.current_page == "settings":
self._settings_ui()
elif self.current_page == "pause":
self._pause_ui()
elif self.current_page == "running":
self._running_ui()
鼠标左键点击触发函数:
def click_event(self, pos):
"""
点击事件处理
:param pos: 当前鼠标坐标,当前页面
"""
# 点击音效
music_sys.play_menu(True)
if self.current_page == "start":
if self.start_continue_button.collidepoint(pos):
self.current_page = "running"
elif self.start_new_button.collidepoint(pos):
self.current_page = "running"
self.player_sys.start_new()
print("start_new_button")
elif self.start_settings_button.collidepoint(pos):
self.current_page = "settings"
elif self.current_page == "pause":
if self.pause_continue_button.collidepoint(pos):
print("pause_continue_button")
self.current_page = "running"
elif self.pause_home_button.collidepoint(pos):
self.current_page = "start"
elif self.current_page == "running":
# 设置玩家点击
self.player_sys.set_animate_pos(pos=pos)
if self.running_pause_button.collidepoint(pos):
self.current_page = "pause"
print("pause_continue_button")
在主窗口中引入:
class MainWindow():
"""
负责主窗口初始化,加载控制系统,刷新页面,检测点击事件
"""
def __init__(self):
# 配置主窗口大小
self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
# 窗口标题
pygame.display.set_caption(WIN_TITLE)
# 图标
icon = pygame.image.load(ICON_PATH).convert_alpha()
pygame.display.set_icon(icon)
# 用于控制刷新帧率
self.clock = pygame.time.Clock()
# 运行标志
self.running_flag = True
# 播放背景音乐
music_sys.play_bgm(True)
# 初始化画面控制器
self.control_sys = ControlSys(self.screen)
def run_game(self):
# 循环处理页面
while self.running_flag:
# 配置刷新率为 60 帧
self.clock.tick(FRASH_SPEED)
# 事件处理
for event in pygame.event.get():
# 退出程序事件
if event.type == pygame.QUIT:
self.running_flag = False
# 关闭背景音乐
music_sys.play_bgm(False)
# 鼠标事件
elif event.type == pygame.MOUSEBUTTONDOWN:
# 检测左键点击
if event.button == pygame.BUTTON_LEFT:
self.control_sys.click_event(pos=event.pos)
# 清空画面
self.screen.fill(BLACK_COLOR)
# 更新页面
self.control_sys.draw_ui()
# 刷新页面
pygame.display.flip()
# 退出程序
pygame.quit()
游戏内部配置
settings 界面接收点击事件后通过改变对应变量来控制配置状态:difficulty_speed_step alpha
等参数会在对应范围内的点击事件中发生变化。
elif self.current_page == "settings":
if self.settings_difficulty_bar.collidepoint(pos):
print("settings_difficulty_bar")
print(pos)
(x, y) = pos
self.settings_difficulty_bar_left_w = x - (self.screen_width - self.settings_bar_total)
// 2 - self.h_margin//2
self.current_page = "settings"
self.fish_sys.difficulty_speed_step = min(self.settings_difficulty_bar_left_w
/self.settings_bar_total, 1)
elif self.settings_volume_bar.collidepoint(pos):
print("settings_volume_bar")
print(pos)
(x, y) = pos
self.settings_volume_bar_left_w = x - (self.screen_width - self.settings_bar_total)
// 2 - self.h_margin//2
self.current_page = "settings"
music_sys.volume_setting(volume=self.settings_volume_bar_left_w
/ self.settings_bar_total)
elif self.settings_light_bar.collidepoint(pos):
(x, y) = pos
self.settings_light_bar_left_w = x - (self.screen_width - self.settings_bar_total)
// 2 - self.h_margin // 2
self.current_page = "settings"
print(self.settings_light_bar_left_w / self.settings_bar_total)
self.alpha = (1-self.settings_light_bar_left_w / self.settings_bar_total) * 255
self.alpha = max(0, min(self.alpha, 255)) # 限制 alpha 在 0 到 255 的范围
elif self.settings_en_button.collidepoint(pos):
self.en_color = BUTTON_COLOR
self.cn_color = FONT_COLOR
self.current_page = "settings"
self.language = "en"
elif self.settings_zh_button.collidepoint(pos):
self.en_color = FONT_COLOR
self.cn_color = BUTTON_COLOR
self.current_page = "settings"
self.language = "zh"
elif self.settings_home_button.collidepoint(pos):
self.current_page = "start"
项目部署
- 解压源码包 这里要注意,解压源码包后,你应该创建一个新的文件夹,例如 example 。然后把 源码包复制 example 内。你的项目结构应当是这样的。
├─example
│ ├─ Fishing # 源码包
│ main.py # 主程序入口
│ requirement.txt # 依赖
│ .........
- 创建虚拟环境 venv用 pycharm 打开 example 文件夹,然后创建一个新的虚拟环境, 注意虚拟环境路径在 example 下,与 Media 同级。
├─example
│ ├─ venv
│ ├─ Fishing # 源码包
│ main.py # 主程序入口
│ requirement.txt # 依赖
│ .........
- 安装依赖包, 在命令框中执行
pip install -r requirement.txt
- 启动 main.py 即可测试
打包exe
配置文件 main.spec
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['main.py'],
pathex=[ ],
binaries=[],
datas=[
(r"assets\fonts", r"assets\fonts"),
(r"assets\images", r"assets\images"),
(r"assets\sounds", r"assets\sounds"),
],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='小鱼爱上钩',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=[r'assets\images\icon.ico'],
onefile=True
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=["python3.dll"],
name='main',
)
注意,新版本的 pyinstaller 单文件夹模式打包后文件路径会出现错误,因为多了 _internal 文件夹,所以打包时在 config 的_current_path 后添加 / "_internal"
_current_path = Path(".").resolve() / "_internal"
打包命令,打包后的结果在 dist 目录下:
pyinstaller .\main.spec
项目源码请私信我获取