Разработка на коленке

"тут должна быть красивая цитата о программировании"

Механизм обработки времени в игровом цикле (Game loop)

2024-03-12 22:45

На идею сделать нормальный игровой цикла меня натолкнула предыдущая заметка - Загрузка изображения и вращение вокруг своего центра.

В этом сниппете я сделал реализацию двух вариантов обработки времени в игровом цикле (GameLoop).

Если зажать кнопку мыши, то имитируется подвисание игры (пока кнопка зажата, не вызывается обновление). На экране в этот время вокруг курсора рисуется зелёный круг.

У приложения есть два режима: "медленный" и "быстрый". В медленном режиме время между вызовами update передаётся в метод "как есть" и используется для вычисления следующей позиции по формуле "скорость * время":

def move_ball(self, delta_time):
    self.ball.position += self.ball.speed * delta_time

И при таком варианте шарик может пролетать через стену (в медленном режиме на видео чёрный фон).

Для "быстрого" режима есть минимально возможное значение delta_time. Для примера я выставил в одну секунду, но в реальном приложении поставил бы 1 / 20 секунды. И помимо этого, вызовы update идут не на всё время, а на отдельные кусочки UPDATE_RATE.

Исходник

import pygame as pg


from pygame import QUIT, KEYDOWN, K_ESCAPE, K_SPACE
from pygame.math import Vector2
from pygame.rect import Rect
from pygame.time import Clock


UPDATE_RATE = 1 / 120

MIN_UPDATE_RATE = 1


class Ball:
    def __init__(self, surface, position, speed, radius):
        self.surface = surface
        self.position = position
        self.speed = speed
        self.radius = radius

        self.color = (100, 100, 200)

    def render(self):
        pg.draw.circle(self.surface, self.color, self.position, self.radius)


class Game:
    def __init__(self, surface):
        self.surface = surface

        self.clock = Clock()
        self.accumulator = 0

        self.normal_bg_color = (200, 200, 200)
        self.slow_bg_color = (20, 20, 20)
        self.bg_color = self.normal_bg_color

        radius = 10
        self.ball = Ball(
            self.surface,
            Vector2(radius, self.surface.get_height() / 2),
            Vector2(200, 0),
            radius
        )

        self.wall = self.create_wall()
        self.wall_color = (200, 10, 10)

        self.slow = False

        self.working = True

    def create_wall(self) -> Rect:
        wall_width = 10
        wall_height = 200
        x = (self.surface.get_width() - wall_width) / 2
        y = (self.surface.get_height() - wall_height) / 2
        return Rect(x, y, wall_width, wall_height)

    def handle_events(self):
        for e in pg.event.get():
            if e.type == QUIT or (e.type == KEYDOWN and e.key == K_ESCAPE):
                self.working = False

            if e.type == KEYDOWN and e.key == K_SPACE:
                self.slow = not self.slow
                self.bg_color = self.slow_bg_color if self.slow else self.normal_bg_color

    def move_ball(self, delta_time):
        self.ball.position += self.ball.speed * delta_time

    def collide_screen(self):
        if self.ball.position.x < self.ball.radius:
            self.ball.position.x = self.ball.radius
            self.ball.speed = -self.ball.speed
        elif self.ball.position.x > (self.surface.get_width() - self.ball.radius):
            self.ball.position.x = self.surface.get_width() - self.ball.radius
            self.ball.speed = -self.ball.speed

    def collide_wall(self):
        if self.ball.position.x + self.ball.radius < self.wall.x or self.ball.position.x - self.ball.radius > self.wall.x + self.wall.width:
            return

        self.ball.speed = -self.ball.speed

    def update(self, delta_time):
        self.move_ball(delta_time)
        self.collide_screen()
        self.collide_wall()

    def render(self):
        self.surface.fill(self.bg_color)
        pg.draw.rect(self.surface, self.wall_color, self.wall)
        self.ball.render()

        if pg.mouse.get_pressed()[0]:
            pg.draw.circle(self.surface, (10, 150, 10), pg.mouse.get_pos(), 10)
        pg.display.update()

    def run(self):
        delta_time = 0
        self.clock.tick()

        while self.working:
            skip = pg.mouse.get_pressed()[0]
            self.handle_events()

            if self.slow:
                if not skip:
                    self.update(delta_time)
            else:
                if not skip:
                    delta_time = min(delta_time, MIN_UPDATE_RATE)
                    self.accumulator += delta_time

                    while self.accumulator >= UPDATE_RATE:
                        self.accumulator -= UPDATE_RATE
                        self.update(UPDATE_RATE)

            self.render()

            if not skip:
                delta_time = self.clock.tick() / 1000


def main():
    pg.init()
    Game(pg.display.set_mode((800, 450))).run()
    pg.quit()


if __name__ == "__main__":
    main()

7/fixed_time_game_loop.py

Загрузка изображения и вращение вокруг своего центра

2024-03-11 22:30

В этом сниппете я сделал загрузку изображения с диска и вращение вокруг центра экрана. Если нажать на какую-нибудь клавишу, то направление вращения меняется на противоположное.

Ключевой момент в этом сниппете в том, что выводить на экран нужно не просто картинку в заданных координатах, а Rect, который получается с заданным центром. Если этого не сделать, а просто вывести картинку так:

self.surface.blit(self.tank, self.center)

то центр картинки будет всё время смещаться.

Для вращения сделал вот такой танк:

Tank

Внешне результат работы сниппета выглядит так:

Shoot letters preview

import pygame as pg 

from math import copysign
from pygame.time import Clock


class Game:
    def __init__(self, surface):
        self.surface = surface

        self.background = (121, 103, 85)

        self.origin = pg.image.load('tank.png')
        self.center = (
            (self.surface.get_width() - self.origin.get_width()) / 2,
            (self.surface.get_height() - self.origin.get_height()) / 2
        )
        self.angle = 0
        self.angle_speed = 3
        self.tank = pg.transform.rotate(self.origin, self.angle)

        self.clock = Clock()
        self.fps = 60

        self.working = True

    def handle_events(self):
        for e in pg.event.get():
            if e.type == pg.QUIT:
                self.working = False
            elif e.type == pg.KEYDOWN:
                if e.key == pg.K_ESCAPE:
                    self.working = False
                else:
                    # change direction
                    self.angle_speed = -self.angle_speed

    def update(self):
        self.angle += self.angle_speed
        self.angle = copysign(abs(self.angle) % 360, self.angle)
        self.tank = pg.transform.rotate(self.origin, self.angle)

    def render(self):
        self.surface.fill(self.background)
        self.surface.blit(self.tank, self.tank.get_rect(center=self.center))
        pg.display.update()


    def run(self):
        while self.working:
            self.handle_events()
            self.update()
            self.render()

            self.clock.tick(self.fps)

def main():

    pg.init()

    size = (800, 450)
    display_surface = pg.display.set_mode(size)
    Game(display_surface).run()

    pg.quit()


if __name__ == "__main__":
    main()

6/load_and_rotate_image.py

"Стрелялка" буквами

2024-03-10 22:30

За основу взял пост, в котором загружал шрифт, и сделал так, чтобы каждая нажатая буква появлялась внизу окна и поднималась вверх.

Рендеринг букв не стал выносить в метод класса, а оставил внутри main, но для хранения данных о букве сделал датакласс Letter.

Shoot letters preview

import pygame as pg

from dataclasses import dataclass
from pygame import QUIT, KEYDOWN, K_ESCAPE
from pygame.font import Font
from pygame.math import Vector2
from pygame.surface import Surface
from pygame.time import Clock
from random import randint


WINDOW_SIZE = (800, 450)

BG_COLOR = (200, 200, 200)

SPEED = Vector2(0, -3)

FPS = 60


@dataclass()
class Letter:
    position: Vector2
    surface: Surface


def next_position(surf):
    return Vector2(
        randint(0, WINDOW_SIZE[0] - surf.get_width()),
        WINDOW_SIZE[1] + surf.get_height()
    )


def main():
    pg.init()

    surface = pg.display.set_mode(WINDOW_SIZE)
    font = Font('Boxy-Bold.ttf', 24)

    items = []
    clock = Clock()

    working = True
    while working:
        # Handle events
        for e in pg.event.get():
            if e.type == QUIT:
                working = False
                continue

            if e.type != KEYDOWN:
                continue

            if e.key == K_ESCAPE:
                working = False

            char = e.unicode.strip()
            if char:
                surf = font.render(char, False, (50, 50, 50))
                items.append(Letter(next_position(surf), surf))

        # Update
        for i in items:
            i.position += SPEED
        items = [i for i in items if i.position.y > -i.surface.get_height()]

        # Render
        surface.fill(BG_COLOR)
        for i in items:
            surface.blit(i.surface, i.position)
        pg.display.update()

        clock.tick(FPS)

    pg.quit()


if __name__ == "__main__":
    main()    

5/shoot_letters.py

Загрузка шрифта из файла

2024-03-06 23:10

Сниппет, в котором я использовал ttf-шрифт для отображения последней нажатой клавиши. При этом кнопки, которые не дают какое-то строковое представление, игнорируются. И чтобы это было чуть динамичнее, я сделал так, чтобы текст бегал за курсором мышки.

Шрифт взял там - https://opengameart.org/content/boxy-bold-truetype-font

Font screenshot

import pygame

from pygame.math import Vector2


def main():
    pygame.init()

    surface = pygame.display.set_mode((800, 450))

    font = pygame.font.Font('Boxy-Bold.ttf', 24)
    rendered = font.render('Ok?', False, (50, 50, 50))

    working = True
    while working:
        for e in pygame.event.get():
            if e.type == pygame.QUIT:
                working = False
            elif e.type == pygame.KEYDOWN:
                if e.key == pygame.K_ESCAPE:
                    working = False
                else:
                    if e.unicode.strip():
                        rendered = font.render(e.unicode.strip(), False, (50, 50, 50))

        surface.fill((200, 200, 200))
        surface.blit(rendered, Vector2(pygame.mouse.get_pos()) + Vector2(20, 20))

        pygame.display.update()

    pygame.quit()


if __name__ == "__main__":
    main()

4/use_font.py

Кнопка с пререндеренным Surface

2024-03-03 23:00

Сниппет, в котором для кнопки создаются несколько объектов Surface. Рисуются они один раз, а потом блитятся (blit) на родительский Surface

Prerendered button

import pygame as pg

from pygame.surface import Surface
from pygame.font import Font


class Button:
    def __init__(self, parent_surface: Surface, position, size):
        self.parent_surface = parent_surface
        self.position = position
        self.size = size
        self.border_width = 3

        self.normal = self.make_normal_button()
        self.hover = self.make_hover_button()
        self.pressed = self.make_pressed_button()

        self.is_hover = False
        self.is_pressed = False

    def make_normal_button(self):
        surface = Surface(self.size, pg.SRCALPHA)

        bg_color = (255, 255, 255)
        border_color = (40, 40, 40)
        shadow_height = 5
        surface.fill(bg_color)
        pg.draw.rect(
            surface,
            border_color,
            pg.rect.Rect(0, 0, self.size[0], self.size[1]),
            self.border_width
        )
        pg.draw.rect(
            surface,
            border_color,
            pg.rect.Rect(0, self.size[1] - shadow_height, self.size[0], shadow_height)
        )

        return surface

    def make_hover_button(self):
        surface = Surface(self.size, pg.SRCALPHA)

        bg_color = (255, 255, 255)
        border_color = (80, 80, 80)
        shadow_height = 5
        surface.fill(bg_color)
        pg.draw.rect(
            surface,
            border_color,
            pg.rect.Rect(0, 0, self.size[0], self.size[1]),
            self.border_width
        )
        pg.draw.rect(
            surface,
            border_color,
            pg.rect.Rect(0, self.size[1] - shadow_height, self.size[0], shadow_height)
        )
        return surface

    def make_pressed_button(self):
        surface = Surface(self.size, pg.SRCALPHA)

        bg_color = (255, 255, 255)
        border_color = (80, 80, 80)
        surface.fill(bg_color)
        pg.draw.rect(
            surface,
            border_color,
            pg.rect.Rect(0, 0, self.size[0], self.size[1]),
            self.border_width + 3
        )
        return surface

    def render(self):
        surface = self.normal

        if self.is_hover:
            surface = self.hover
            if self.is_pressed:
                surface = self.pressed

        self.parent_surface.blit(surface, self.position)


class Game:
    def __init__(self, display_surface: Surface):
        self.display_surface = display_surface

        button_size = (200, 60)
        self.button = Button(
            self.display_surface,
            (
                (self.display_surface.get_width() - button_size[0]) // 2,
                (self.display_surface.get_height() - button_size[1]) // 2,
            ),
            button_size
        )

        self.working = True

    def process_events(self):
        for e in pg.event.get():
            if e.type == pg.QUIT or (e.type == pg.KEYDOWN and e.key == pg.K_ESCAPE):
                self.working = False

        mouse_pos = pg.mouse.get_pos()
        button_rect = pg.rect.Rect(
            self.button.position[0],
            self.button.position[1],
            self.button.size[0],
            self.button.size[1]
        )
        self.button.is_hover = button_rect.collidepoint(mouse_pos)
        self.button.is_pressed = pg.mouse.get_pressed()[0]

    def render(self):
        self.display_surface.fill((220, 220, 220))
        self.button.render()
        pg.display.update()

    def run(self):
        while self.working:
            self.process_events()
            self.render()


def main():
    pg.init()
    display_surface = pg.display.set_mode((800, 450))
    Game(display_surface).run()
    pg.quit()


if __name__ == "__main__":
    main()

3/prerendered_button_surface.py