歌者文明清理员 发表于 2023-7-28 14:14:41

【歌者-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:



陈尚涵 发表于 2023-7-28 14:23:03

lz没介绍星球怎么动的。这个星球是按着规定好的路线走的吗?还是有吸引或者是卫星关系?

Ewan-Ahiouy 发表于 2023-7-28 15:09:57

歌者文明清理员 发表于 2023-7-28 15:09
运行成功了吗

还没,404了{:10_269:}

Ewan-Ahiouy 发表于 2023-7-28 15:14:04

{: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:17:01

Ewan-Ahiouy 发表于 2023-7-28 15:14


你叫我改源代码?!{:10_257:}

cjjJasonchen 发表于 2023-7-28 15:51:46

喔!来喽

歌者文明清理员 发表于 2023-7-28 14:19:38

@一点沙 @liuhongrun2022 @学习编程中的Ben @sfqxx @zhangjinxuan @Ewan-Ahiouy @鱼cpython学习者 @cjjJasonchen @hellomyprogram

liuhongrun2022 发表于 2023-7-28 14:37:27

必须支持!

Ewan-Ahiouy 发表于 2023-7-28 15:07:09

{:10_257:}{:10_257:}{:10_257:}

Ewan-Ahiouy 发表于 2023-7-28 15:07:45

@python爱好者. @cjjJasonchen {:10_257:}

歌者文明清理员 发表于 2023-7-28 15:09:29

Ewan-Ahiouy 发表于 2023-7-28 15:07
@python爱好者. @cjjJasonchen

运行成功了吗

hellomyprogram 发表于 2023-7-28 15:17:28

已经Star力!

cjjJasonchen 发表于 2023-7-28 15:52:16

Ewan-Ahiouy 发表于 2023-7-28 15:07
@python爱好者. @cjjJasonchen

谢邀

歌者文明清理员 发表于 2023-7-28 16:00:15

liuhongrun2022 发表于 2023-7-28 14:37
必须支持!

我换头像了

liuhongrun2022 发表于 2023-7-28 16:02:14

歌者文明清理员 发表于 2023-7-28 16:00
我换头像了

hacker

歌者文明清理员 发表于 2023-7-28 16:03:22

@cjjJasonchen 你回复了什么

cjjJasonchen 发表于 2023-7-28 16:05:57

歌者文明清理员 发表于 2023-7-28 16:03
@cjjJasonchen 你回复了什么

没事,我把不小心多加了个后缀

Ewan-Ahiouy 发表于 2023-7-28 16:06:12

{: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)

cjjJasonchen 发表于 2023-7-28 16:06:35

我靠太帅了把!我不偷懒了我去写程序了!{:10_254:}

歌者文明清理员 发表于 2023-7-28 16:11:54

Ewan-Ahiouy 发表于 2023-7-28 15:07
@python爱好者. @cjjJasonchen

原来是你给我点了star 呀,能不能 互关?我已经关注你了
页: [1] 2 3 4 5 6 7 8
查看完整版本: 【歌者-Pygame】pygame 天体运动模拟(附教程)【8-5 更新】