【歌者-Pygame】pygame 天体运动模拟(附教程)【8-5 更新】
本帖最后由 歌者文明清理员 于 2024-1-29 23:27 编辑最新消息:本贴将停止更新,但程序会一直更新。请在 GitHub 上支持我!https://github.com/dddddgz/star-motion-simulate
pygame 天体运动模拟
在线展示:
https://www.bilibili.com/video/BV1Tz4y1s7Lj
简介
推荐阅读:神秘链接
友情链接:Pygame 模拟车辆运动(7-31更新)
感谢各位的支持
庆祝精华,在这里放上一些鱼币!
其实之前做过一个,但代码丢了 T_T 也不好看
强烈建议去「GitHub」上 Star 一下,不然我英文就白翻译了是吧{:10_315:} (不过是机翻{:10_256:})
并且可以「Follow」一下我{:10_330:}
感谢
FishC 用户名|贡献|GitHub 用户名
hellomyprogram|修复 3 bugs,提出 9 个意见|hehe-minecraft
cjjJasonchen|修复 3 bugs,大力支持我|未知
琅琊王朝|添加了 Russian readme|fortress1019
运行程序
在开始教程以前,我们需要先看看效果是啥样{:10_297:}
下载项目 && pip
如果你的电脑上没有安装 Git……那可太亏了,Git 可以方便版本管理,而不用复制……Git 完整教程可以看 -> 小甲鱼的教程(链接)
这里可以看到所有版本:
Releases
本文的版本:
V1.0
「解压缩」就不用多说了吧,7-Zip / NanaZip / BandiZip
本项目使用了「PyINI、Pygame」库,需要使用 pip 安装:
# macOS
python3 -m pip install pyini pygame
# Windows
python -m pip install pyini pygame
配置 && 运行
因为看见了这个,觉得如果是真的要在 GitHub 上做好一个项目的话,就应该做到国际化,然后为了方便申请精华,就把中文加上了,所以默认语言是英文
先设置成中文再说!
打开解压好的文件夹,在里面打开 config 文件夹,找到 config.ini 文件,打开它(文件内容可能不一样,这是因为版本在不断更新中,但选项一直都在,可以用 Ctrl + F 搜索)
下的 default 选项表示语言,这里改成 "zh",表示中文,然后保存,不要关掉 config.ini 的窗口,还要设置加载的模拟。
再打开 simulation 文件夹,可以看到这里有很多我做的模拟,模拟内容都写在文件名上了,如 “solar_system”(太阳系)、“3body”(三体)等等。
选择一个你喜欢的,复制文件名(不含后缀!!!),粘贴到 config.ini 的 simulation 下的 file 上。
现在好啦,看效果吧{:10_248:}运行 main.py 即可!
备注:要用 macOS/Linux 的「终端」或者 Windows Terminal 运行,因为使用了「Rich」库~
效果预览:
教程
教程开始!{:10_300:}(小甲鱼的 Python 教程总得有 80% 能看懂吧,如果不行的话那你就尽情的玩游戏吧{:10_298:})
如果你看得懂 Python 教程,但不懂 Pygame,可以去官方文档学一下 -> Pygame 官方文档
别说不会英语,英语是程序员 / 黑客必会的语言,你想想 Python 是不是英文的?GitHub?StackOverflow?
Pygame 基础搭建
如果你学过 Pygame,那么这个必须得背下来!
import pygame
pygame.init()
G = 6
screen = pygame.display.set_mode((1000, 1000))
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
pygame.display.flip()
pygame.quit()
不解释
接下来,捕捉「键盘」事件:
--snip--
running = False
elif event.type == pygame.KEYDOWN:
print("键盘按下了!")
pygame.display.flip()
--snip--
当 event.type 为 pygame.KEYDOWN 时,event 有一个 key 属性,表示哪个键被按下了。
那么,这么改就行了:
--snip--
elif event.type == pygame.KEYDOWN:
print("你按下了", pygame.key.name(event.key), "键")
if event.key == pygame.K_LEFT:
print(" left key")
--snip--
完美!现在已经实现输出按下的键的名称,且识别左键(←)了。
现在要建立一个 rel 变量,用于标记屏幕被拖动了多少。
思路:
例如按下左键,就应该让视角往左,看见右边的东西,所以对于每个天体来说,应该都向右移动,rel 应该变大。
--snip--
rel =
running = True
while running:
print(rel)
--snip--
elif event.key == pygame.K_LEFT:
rel += 10
--snip--
★★★重要★★★:Pygame 中,坐标的规则如下:
另外为了方便更改代码,因为上下左右的移动距离都应该一样,所以新增 movement 变量,用来表示每一次按键的移动距离。
--snip--
rel =
movement = 10
running = True
--snip--
if event.key == pygame.K_LEFT:
rel += movement
elif event.key == pygame.K_RIGHT:
rel -= movement
elif event.key == pygame.K_UP:
rel += movement
elif event.key == pygame.K_DOWN:
rel -= movement
--snip--
现在,按下 ↑ ↓ ← → 键,就可以看到 rel 在变化了。
Star Sprite
新建 objects.py,这个模块用来储存游戏中的动画精灵。
首先定义一个天体:
每个天体都有 name, radius, color, pos, velocity, mass 这几个属性。
为了游戏效果需要,我们想让某些天体(比如太阳)的位置始终不变,即可以对其他天体进行吸引,但自身不动,添加 locked 属性。
因为要开源到 GitHub 的原因,希望能被广大开发者借鉴,我的源代码加上类型注解,并且写的很漂亮哦~ 其中 typing 库就很重要
import pygame
class Star(pygame.sprite.Sprite):
def __init__(self, name, radius, color, x, y, vx, vy, mass, locked=False):
self.name = name
self.radius = radius
self.color = color
self.x = x
self.y = y
self.vx = vx
self.vy = vy
self.mass = mass
self.locked = locked
作为一个动画精灵,还需要在里面调用超类 Sprite 的初始化方法 __init__(),且还要有 image 和 rect 属性。
为了简化计算,这里的天体都是标准的圆形,可以用 pygame.draw.circle() 方法。
但是,每个天体的 surface 的背景色需要是透明的。
这就需要用到 convert_alpha() 方法了。pygame.Surface 默认不能设置透明色,需要自己额外转换。
另外,convert_alpha() 只是转换成可以设置透明色的 Surface,设置透明色还需要用 fill() 方法。
RGBA 听说过没?只需要将 A 设置为 0,这样不管 RGB 怎么变,始终是透明的。
代码改着改着,就成了这样:
--snip--
self.locked = locked
self.image = pygame.Surface((radius * 2, radius * 2)).convert_alpha()
self.image.fill((0, 0, 0, 0))
pygame.draw.circle(self.image, color, (radius, radius), radius, 0)
self.rect = self.image.get_rect()
既然半径是 radius,那么天体 surface 的边长就是 radius * 2,中心点是 (radius, radius)。
还有一点没有考虑到:天体的位置。
当天体的位置经过引力计算后,会发生改变,所以每次移动天体后都要有一个合适的函数来设置天体位置。
这个函数的逻辑如下:
天体的 x、y 属性表示天体的 x 坐标、y 坐标,rel 属性表示偏移量,所以只需要把它们加起来即可。
--snip--
self.rect = self.image.get_rect()
self.flush()
def flush(self):
self.rect.centerx = self.x + rel
self.rect.centery = self.y + rel
然后,添加 info 属性(使用了 property 装饰器),目的是为了后续计算位置时能轻松获取需要的天体信息。
还有 __repr__() 方法,在检测碰撞时需要通过它来控制当提到天体时显示的文字。
修改后的代码:
--snip--
def __repr__(self):
return self.name
__str__ = __repr__
@property
def info(self):
return self.x, self.y, self.vx, self.vy, self.mass
--snip--
为了方便 objects.py 和 main.py 之间设置游戏的设置,以及代码的易读性 && 易维护性,新增 Config 类,代码更改如下:
# main.py
--snip--
screen = pygame.display.set_mode((1000, 1000))
movement = 10
--snip--
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_LEFT:
Config.rel += movement
elif event.key == pygame.K_RIGHT:
Config.rel -= movement
elif event.key == pygame.K_UP:
Config.rel += movement
elif event.key == pygame.K_DOWN:
Config.rel -= movement
--snip--
while running:
for event in pygame.event.get():
--snip--
# objects.py
import pygame
class Config:
rel =
--snip--
def flush(self):
self.rect.centerx = self.x + Config.rel
self.rect.centery = self.y + Config.rel
对了,现在运行没天体,所以没效果也没有,不如加个太阳康康?
import pygame
from objects import *
pygame.init()
screen = pygame.display.set_mode((1000, 1000))
sprites = [
# name radius color x y vx vy mass locked
Star("Sun", 10, "yellow", 500, 500, 0, 0, 100, True)
]
movement = 10
--snip--
考虑到部分鱼油可能用小键盘来操作,小键盘中 ↑ ↓ ← → 分别对应 8 2 4 6,加上一个判断
--snip--
elif event.type == pygame.KEYDOWN:
if event.key in (pygame.K_LEFT, pygame.K_KP_4):
Config.rel += movement
elif event.key in (pygame.K_RIGHT, pygame.K_KP_6):
Config.rel -= movement
elif event.key in (pygame.K_UP, pygame.K_KP_8):
Config.rel += movement
elif event.key in (pygame.K_DOWN, pygame.K_KP_2):
Config.rel -= movement
--snip--
好啦,算是终于可以运行啦
O_O 我似乎忘记了 blit 天体,这样可以把天体绘制到窗口(screen)上
--snip--
while running:
screen.fill((0, 0, 0))
for sprite in sprites:
screen.blit(sprite.image, sprite.rect)
--snip--
鼠标 Event
上个部分讲到放一个太阳,键盘上下左右对游戏体验者不太友好
但是 Pygame 不支持鼠标拖动的操作……按照甲鱼一贯的作风,托更,没有好用的工具就自己造工具!
先来分析一下 Pygame 中都有哪些「鼠标事件」:
看来得组合事件了{:10_277:}
想想,鼠标拖动时鼠标是按下的……
总不能一边 MOUSEBUTTONDOWN 一边 MOUSEBUTTONUP 吧{:10_255:}
答案是,鼠标按下 + 鼠标移动 = 拖动
所以「设个变量」来记录你的鼠标发生的一切。
代码:
drag = False
--snip--
Config.rel -= movement
elif event.type == pygame.MOUSEBUTTONDOWN:
drag = True
elif event.type == pygame.MOUSEBUTTONUP:
drag = False
elif event.type == pygame.MOUSEMOTION:
if drag:
# do something...
--snip--
很明显,拖动后要让 rel 跟着鼠标的位移走,相当于鼠标动多少,它就动多少
可是怎么知道鼠标移动了多少呢?
回顾这张图:
代码不就出来了:
--snip--
if drag:
Config.rel += event.rel
Config.rel += event.rel
pygame.display.flip()
--snip--
要不再来个「缩放」的功能?
缩放后的位置 = 原位置 * 缩放比例
思路如下:
[*]更改 Star.flush() 函数的坐标计算规则
[*]在 Event Loop 里检测鼠标缩放事件
首先,更改 objects.py,添加缩放比例的主要逻辑:
--snip--
rel =
scale = 1.0
--snip--
def flush(self):
self.rect.centerx = (self.x + Config.rel) * scale
self.rect.centery = (self.y + Config.rel) * scale
然后,添加鼠标滚轮事件。这个需要用到 event.button 属性,这是一个整数值,所表示的意思如下:
属性值|意思
1|左键
2|中键(点击了滚轮)
3|右键
4|滚轮向上滚
5|滚轮向下滚
所以 main.py 更改如下:
--snip--
def zoom(direction, each=0.02):
if direction > 0:
Config.scale += each
if Config.scale > 10:
Config.scale = 10
elif direction < 0:
Config.scale -= each
if Config.scale < 0.02:
Config.scale = 0.02
def change_view(move_x, move_y):
--snip--
elif event.type == pygame.MOUSEBUTTONUP:
if event.button == 1:
drag = False
elif event.button == 4:
zoom(1)
elif event.button == 5:
zoom(-1)
--snip--
解释:
zoom() 的参数可以是正数或者负数,正数表示放大,负数则相反,然后在 Event Loop 中调用它即可。
计算轨道
算轨道,这是我的噩梦啊啊啊……
不要问算法的原理,找以前的项目,然后在网上一通搜索得来的
我找到的算法:
--snip--
def move(t):
for sprite1 in sprites:
x1, y1, vx1, vy1, m1 = sprite1.info
ax1, ay1 = 0, 0
for sprite2 in sprites:
if sprite1 is sprite2:
continue
x2, y2, vx2, vy2, m2 = sprite2.info
dx = x2 - x1
dy = y2 - y1
r = (dx ** 2 + dy ** 2) ** 0.5
f = G * m1 * m2 / (r ** 2)
accel = f / m1
ax1 += accel * (dx / r)
ay1 += accel * (dy / r)
x, y = (
x1 + vx1 * t + 0.5 * ax1 * (t ** 2),
y1 + vy1 * t + 0.5 * ay1 * (t ** 2)
)
tempx, tempy = sprite1.x, sprite1.y
if not sprite1.locked:
sprite1.x, sprite1.y = x, y
sprite1.vx = (x - tempx) / t
sprite1.vy = (y - tempy) / t
del tempx, tempy
sprite1.flush()
--snip--
差不多就这样吧{:10_283:}
首先,把计算距离封装到函数里:
--snip--
def get_distance(sprite1, sprite2):
x1, x2 = sprite1.x, sprite2.x
y1, y2 = sprite1.y, sprite2.y
dx = x2 - x1
dy = y2 - y1
return (dx ** 2 + dy ** 2) ** 0.5
def move(t):
--snip--
x2, y2, vx2, vy2, m2 = sprite2.info
dx = x2 - x1
dy = y2 - y1
r = get_distance(sprite1, sprite2)
--snip--
然后「处理碰撞」:
圆形的碰撞规则是 「r1 + r2 >= d」
其中 d 表示距离,dx2+dy2=d2,也就是代码里的 「get_distance()」 函数。
r1 和 r2 分别表示两个圆形的半径。
代码:
def is_collide(sprite1, sprite2):
r1, r2 = sprite1.radius, sprite2.radius
return r1 + r2 > get_distance(sprite1, sprite2)
def move(t):
--snip--
r = get_distance(sprite1, sprite2)
if is_collide(sprite1, sprite2):
# do something...
--snip--
当两个天体碰撞时,首先要判断谁质量更大,谁质量更小。
代码:
--snip--
r = get_distance(sprite1, sprite2)
if is_collide(sprite1, sprite2):
heavier = sprite1 if sprite1.mass > sprite2.mass else sprite2
lighter = sprite2 if heavier is sprite1 else sprite1
--snip--
解释:
这里使用了「三元表达式」,首先判断谁的质量更大(赋值给 heavier),再判断 heavier 是不是 sprite1。
先来个输出吧……
--snip--
lighter = sprite2 if heavier is sprite1 else sprite1
print(f"{heavier} collide with {lighter}")
--snip--
呃{:10_302:}忘记加天体了{:10_277:}
--snip--
sprites = [
# name radius color x y vx vymass locked
Star("Sun", 10, "yellow", 500, 500, 0, 0,100, True),
Star("Earth",5, "blue", 600, 500, 0, -2, 1)
]
--snip--
{:10_272:}咋还没动?
并没有调用 move() 函数。
另外,如果不使用 pygame.time.Clock() 来控制游戏的 fps(frame per second)的话,游戏的循环运行速度将很难求得,导致游戏运行速度不正常……
pygame.time.Clock.tick() 方法用于控制 fps。
代码:
--snip--
clock = pygame.time.Clock()
running = True
while running:
clock.tick(30)
screen.fill((0, 0, 0))
move(1)
--snip--
一点“小”改进
如果你认真地完成到了上一步,你就会看见地球在绕着太阳转……
但是对于强迫症患者来说……
没错,标题和图标都得改改{:10_336:}
直接上答案:pygame.display.set_caption() 和 pygame.display.set_icon()
首先,我们需要先准备一个图标文件 ->
然后加载这个图片:
--snip--
image = pygame.image.load("icon.png")
sprites = [
--snip--
完整代码:
--snip--
pygame.display.set_caption("Pygame 天体运动模拟")
pygame.display.set_icon(pygame.image.load("icon.png"))
sprites = [
--snip--
还有一点……天体的「轨迹」没有展现出来……
首先,定义 add_to_trail() 方法,记录当前坐标;
--snip--
return self.x, self.y, self.vx, self.vy, self.mass
def add_to_trail(self):
self.trail.append((self.x, self.y))
--snip--
写 self.x、self.y 的原因:self.rect 的位置经过 rel 和 scale 的变换,这导致坐标不准确;而 self.x、self.y 没有经过变换,是实际的坐标。
可是还要让坐标 1 秒后自动清除……不如定义 TrailPoint 类,记录时间戳:
--snip--
class TrailPoint(tuple):
def __init__(self, pos):
self.pos = pos
self.time = time()
def __iter__(self):
yield self.pos
yield self.pos
def get_time(self):
return time() - self.time
class Star(pygame.sprite.Sprite):
--snip--
def add_to_trail(self):
self.trail.append(TrailPoint((self.x, self.y)))
然后,在主循环提取 trail:
lz没介绍星球怎么动的。这个星球是按着规定好的路线走的吗?还是有吸引或者是卫星关系? 歌者文明清理员 发表于 2023-7-28 15:09
运行成功了吗
还没,404了{:10_269:} {:10_277:}
Traceback (most recent call last):
File "E:\WYH\下载\star-motion-simulate-1\main.py", line 9, in <module>
from objects import *
File "E:\WYH\下载\star-motion-simulate-1\objects.py", line 10, in <module>
class Config:
File "E:\WYH\下载\star-motion-simulate-1\objects.py", line 11, in Config
rel: list =
TypeError: 'type' object is not subscriptable Ewan-Ahiouy 发表于 2023-7-28 15:14
你叫我改源代码?!{:10_257:} 喔!来喽
@一点沙 @liuhongrun2022 @学习编程中的Ben @sfqxx @zhangjinxuan @Ewan-Ahiouy @鱼cpython学习者 @cjjJasonchen @hellomyprogram 必须支持! {:10_257:}{:10_257:}{:10_257:} @python爱好者. @cjjJasonchen {:10_257:}
Ewan-Ahiouy 发表于 2023-7-28 15:07
@python爱好者. @cjjJasonchen
运行成功了吗 已经Star力! Ewan-Ahiouy 发表于 2023-7-28 15:07
@python爱好者. @cjjJasonchen
谢邀 liuhongrun2022 发表于 2023-7-28 14:37
必须支持!
我换头像了 歌者文明清理员 发表于 2023-7-28 16:00
我换头像了
hacker @cjjJasonchen 你回复了什么 歌者文明清理员 发表于 2023-7-28 16:03
@cjjJasonchen 你回复了什么
没事,我把不小心多加了个后缀 {:10_243:}
Traceback (most recent call last):
File "E:\WYH\下载\star-motion-simulate-1\main.py", line 9, in <module>
from typing import list as List, tuple as Tuple
ImportError: cannot import name 'list' from 'typing' (C:\Program Files\Python38\lib\typing.py) 我靠太帅了把!我不偷懒了我去写程序了!{:10_254:} Ewan-Ahiouy 发表于 2023-7-28 15:07
@python爱好者. @cjjJasonchen
原来是你给我点了star 呀,能不能 互关?我已经关注你了