Лабораторные работы по Python, ЛФИ, 1 семестр
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

293 lines
12 KiB

from __future__ import annotations
import pygame
import sys
from dataclasses import dataclass, field
import time
import numpy as np
import typing
3 years ago
import json
import os
from random import uniform, choice
if typing.TYPE_CHECKING:
from nptyping import NDArray
pygame.init()
# width and height of the main part of the screen + vertical offset for general info
W, H, OFFSET = 900, 800, 50
# ball's lifetime (seconds)
DEFAULT_TTL = 3
# ball's radius (default)
DEFAULT_RADIUS = 20
# time delta, ms
dt = 10
# load the font
font = pygame.font.Font('orbitron-medium.otf', 15)
font_large = pygame.font.Font('orbitron-medium.otf', 25)
3 years ago
font_even_larger = pygame.font.Font('orbitron-medium.otf', 40)
# Orbitron doesn't support unicode, so I have to get another font for the scoreboard
unicode_font_large = pygame.font.Font('IntroRust.otf', 25)
N_BALLS = 18
3 years ago
ENDGAME_BUTTON_RECT = (850, 15, 20, 20)
BGCOLOR = (40, 46, 70)
3 years ago
# List of ball types: [(radius, speed, color, points, shape, deviation_radius)]
BALL_TYPES = [
(25, 0.035, (185, 20, 50), 10, 'circle', 0),
(20, 0.05, (60, 0, 160), 20, 'circle', 0),
(18, 0.055, (220, 0, 0), 30, 'circle', 0),
(30, 0.055, (220, 0, 0), 40, 'triangle', 30),
]
@dataclass
class Game:
"""
Class for everything connected to the game
:param score: user's score
:param balls: the list of balls
:param target_ball_number: the target number of balls on screen at a given time
3 years ago
:param game_end_time: the timeout of the game
:param game_ended: true if the game has ended
:param user_name: user's name for the scoreboard
"""
score: int = 0
balls: typing.List[Ball] = field(default_factory=lambda: [Ball.new_random() for _ in range(N_BALLS)])
target_ball_number: int = N_BALLS
3 years ago
game_end_time: float = field(default_factory=lambda: time.time() + 30)
game_ended: bool = False
scoreboard: typing.List[typing.Tuple[str, int]] = field(default_factory=lambda: list()
if not os.path.exists('scoreboard.json')
else json.load(open('scoreboard.json')))
user_name: str = ''
def draw_everything(self, screen: pygame.Surface):
"""
Draw everything (for example, balls) on screen
:param screen: the screen
"""
for ball in self.balls:
ball.draw_the_ball(screen)
3 years ago
if not self.game_ended:
pygame.draw.rect(screen, (255, 255, 255), pygame.Rect(0, 0, W, OFFSET))
text_surface = font_large.render(f'Score: {self.score}', True, (0, 0, 0))
screen.blit(text_surface, (360, 15))
time_left = self.game_end_time - time.time()
time_text_surface = font_large.render(
f'{int(time_left / 60)}:{int(time_left % 60):02}', False,
(0, 0, 0) if time_left > 10 else (220, 0, 0))
screen.blit(time_text_surface, (760, 15))
# draw the button to end game (which is a red rect)
pygame.draw.rect(screen, (255, 0, 0), ENDGAME_BUTTON_RECT)
else:
screen.fill((*BGCOLOR, 150), None, pygame.BLEND_RGBA_MULT)
game_over_text = font_even_larger.render(f'Game over', True, (255, 0, 0))
score_text = font_even_larger.render(f'Score: {self.score}', True, (255, 255, 255))
screen.blit(game_over_text, (W / 2 - game_over_text.get_width() / 2,
-180 + H / 2 - game_over_text.get_height() / 2))
screen.blit(score_text, (W / 2 - score_text.get_width() / 2, -120 + H / 2 - score_text.get_height() / 2))
y = H / 2
if len(self.scoreboard):
scoreboard_title = font_large.render('Scoreboard:', False, (255, 255, 255))
screen.blit(scoreboard_title, (W / 2 - scoreboard_title.get_width() / 2,
-40 + H / 2 - scoreboard_title.get_height() / 2))
y = -30 + H / 2 + scoreboard_title.get_height() / 2
# Zip with a range of 5 instead of enumerating so that we show only the first 5 records
for i, record in zip(range(5), sorted(self.scoreboard, key=lambda record: -record[1])):
text = unicode_font_large.render(f'{i + 1}. {record[0]} - {record[1]}', True, (255, 255, 255))
y += 10 + text.get_height()
screen.blit(text, (200, y))
username_prompt = font_large.render('Your name:', False, (255, 255, 255))
screen.blit(username_prompt, (200, y + 100))
username_current = unicode_font_large.render(self.user_name, False, (255, 255, 255))
pygame.draw.rect(
screen,
[int(v * 0.5 + 255 * 0.5) for v in BGCOLOR],
(200 + username_prompt.get_width() + 30, y + 80, max(400, username_current.get_width() + 20),
username_current.get_height() + 20))
screen.blit(username_current, (200 + username_prompt.get_width() + 40, y + 90))
def process_click(self, x: float, y: float):
"""
3 years ago
Process mouse click, check if the user clicked on a ball or the endgame button and process it
:param x: click's x coordinate
:param y: click's y coordinate
"""
3 years ago
e_x = ENDGAME_BUTTON_RECT[0] <= x <= ENDGAME_BUTTON_RECT[0] + ENDGAME_BUTTON_RECT[2]
e_y = ENDGAME_BUTTON_RECT[1] <= y <= ENDGAME_BUTTON_RECT[1] + ENDGAME_BUTTON_RECT[3]
if e_x and e_y:
self.game_ended = True
self.user_name = ''
return
if self.game_ended:
return
for i in range(len(self.balls)):
if self.balls[i].is_point_inside(x, y - OFFSET):
self.score += self.balls[i].points
self.balls.pop(i)
break
3 years ago
def process_keypress(self, event):
"""
Process keypress for name input
:param event: the keypress event
"""
if event.key == pygame.K_BACKSPACE:
self.user_name = self.user_name[:-1]
else:
self.user_name += event.unicode
self.write_scoreboard()
def tick(self, dt: float):
"""
Progress for some time
dt: time period, ms
"""
3 years ago
if time.time() > self.game_end_time and not self.game_ended:
self.game_ended = True
self.user_name = ''
if len(self.balls) < self.target_ball_number:
n_balls = round((self.target_ball_number - len(self.balls))) # * (1 - 0.9 ** dt))
self.balls.extend([Ball.new_random() for _ in range(n_balls)])
for ball in self.balls:
ball.tick(dt)
3 years ago
def write_scoreboard(self):
"""
Save scoreboard (`self.scoreboard`) to scoreboard.json as JSON with the new record
"""
if not self.user_name:
return
with open('scoreboard.json', 'w') as f:
f.write(json.dumps(sorted(self.scoreboard + [(self.user_name, self.score)], key=lambda record: -record[1])))
@dataclass
class Ball:
"""
The ball the user has to catch
:param points: the number of points the user gets for catching this ball
:param position: ball's position on the screen
:param velocity: ball's velocity, px/ms
:param time_of_death: the time when this ball will be destroyed. Its death is pre-determined, which is probably
very sad and depressing
:param radius: the radius of the ball on screen in pixels
:param color: ball's color
3 years ago
:param shape: shape of the ball (circle, triangle)
:param deviation_radius: if the radius is >0, the ball will be rotating arond its actual trajectory
:param deviation_angle: the phase of the ball's rotation
"""
points: int = 10
position: NDArray[(2,), float] = field(default_factory=lambda: np.array([uniform(0, W), uniform(0, H)]))
velocity: NDArray[(2,), float] = field(default_factory=lambda: np.array([0, 0]))
time_of_death: float = field(default_factory=lambda: time.time() + DEFAULT_TTL)
radius: float = DEFAULT_RADIUS
color: typing.Tuple[int, int, int] = (255, 0, 0)
3 years ago
shape: str = 'circle'
deviation_radius: float = 0
deviation_angle: float = 0
@classmethod
def new_random(cls) -> Ball:
3 years ago
radius, speed, color, points, shape, deviation_radius = choice(BALL_TYPES)
angle = uniform(0, np.pi)
3 years ago
return cls(points, radius=radius, color=color, velocity=speed * np.array([np.sin(angle), np.cos(angle)]),
shape=shape, deviation_radius=deviation_radius)
def draw_the_ball(self, screen: pygame.Surface):
"""
Just draw the ball on the screen
:param screen: the screen, obviously
"""
3 years ago
x, y = self.position_with_shift()[0], self.position_with_shift()[1] + OFFSET
if self.shape == 'triangle':
angles = [0, np.pi * 2 / 3, np.pi * 4 / 3]
pygame.draw.polygon(screen, self.color, [
(
x + self.radius * np.sin(angle + self.deviation_angle),
y + self.radius * np.cos(angle + self.deviation_angle)
) for angle in angles])
else:
pygame.draw.circle(screen, self.color, [x, y], self.radius)
text_surface = font.render(str(self.points), False, (0, 0, 0))
screen.blit(text_surface, (
3 years ago
round(x - text_surface.get_rect().width / 2),
round(y - text_surface.get_rect().height / 2)
))
3 years ago
def position_with_shift(self) -> typing.Tuple[float, float]:
"""
Get position including deviation (rotation arond the true center)
"""
return (
self.position[0] + self.deviation_radius * np.cos(self.deviation_angle),
self.position[1] + self.deviation_radius * np.sin(self.deviation_angle)
)
def is_point_inside(self, x: float, y: float) -> bool:
"""
3 years ago
Check if a given point is inside this ball (to handle click events)
:param x: point's x coordinate
:param y: point's y coordinate
"""
3 years ago
return (self.position_with_shift()[0] - x) ** 2 + (self.position_with_shift()[1] - y) ** 2 <= self.radius ** 2
def tick(self, dt: float):
"""
Progress for time dt (move the ball according to speed)
dt: time in ms
"""
self.position += self.velocity * dt
if self.position[0] - self.radius < 0:
self.position[0] = self.radius
self.velocity[0] *= -1
if self.position[0] >= W - self.radius:
self.position[0] = W - self.radius
self.velocity[0] *= -1
if self.position[1] < self.radius:
self.position[1] = self.radius
self.velocity[1] *= -1
if self.position[1] >= H - self.radius:
self.position[1] = H - self.radius
self.velocity[1] *= -1
3 years ago
self.deviation_angle += dt * 2 * np.pi / 2000
# create the display surface object
screen = pygame.display.set_mode((W, H + OFFSET))
game = Game()
# set the window name
pygame.display.set_caption("Capture the ball!")
# the main loop
while True:
# time delay of dt
pygame.time.delay(dt)
# iterate over the list of Event objects
# that was returned by pygame.event.get() method.
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit(0)
if event.type == pygame.MOUSEBUTTONUP:
game.process_click(*pygame.mouse.get_pos())
3 years ago
if event.type == pygame.KEYDOWN:
game.process_keypress(event)
# clear the screen
3 years ago
screen.fill(BGCOLOR)
game.tick(dt)
game.draw_everything(screen)
pygame.display.update()