|
|
|
from __future__ import annotations
|
|
|
|
import pygame
|
|
|
|
import sys
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
import time
|
|
|
|
import numpy as np
|
|
|
|
import typing
|
|
|
|
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)
|
|
|
|
N_BALLS = 18
|
|
|
|
|
|
|
|
# List of ball types: [(radius, speed, color, points)]
|
|
|
|
BALL_TYPES = [(25, 0.035, (185, 20, 50), 10), (18, 0.055, (220, 0, 0), 30), (20, 0.05, (60, 0, 160), 20)]
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
"""
|
|
|
|
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
|
|
|
|
|
|
|
|
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)
|
|
|
|
pygame.draw.rect(screen, (255, 255, 255), pygame.Rect(0, 0, W, OFFSET))
|
|
|
|
text_surface = font_large.render(f'Score: {self.score}', False, (0, 0, 0))
|
|
|
|
screen.blit(text_surface, (360, 15))
|
|
|
|
|
|
|
|
|
|
|
|
def process_click(self, x: float, y: float):
|
|
|
|
"""
|
|
|
|
Process mouse click, check if the user clicked on a ball and process it
|
|
|
|
:param x: click's x coordinate
|
|
|
|
:param y: click's y coordinate
|
|
|
|
"""
|
|
|
|
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
|
|
|
|
|
|
|
|
def tick(self, dt: float):
|
|
|
|
"""
|
|
|
|
Progress for some time
|
|
|
|
|
|
|
|
dt: time period, ms
|
|
|
|
"""
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
"""
|
|
|
|
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)
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def new_random(cls) -> Ball:
|
|
|
|
radius, speed, color, points = choice(BALL_TYPES)
|
|
|
|
angle = uniform(0, np.pi)
|
|
|
|
return cls(points, radius=radius, color=color, velocity=speed * np.array([np.sin(angle), np.cos(angle)]))
|
|
|
|
|
|
|
|
def draw_the_ball(self, screen: pygame.Surface):
|
|
|
|
"""
|
|
|
|
Just draw the ball on the screen
|
|
|
|
:param screen: the screen, obviously
|
|
|
|
"""
|
|
|
|
pygame.draw.circle(screen, self.color, [self.position[0], self.position[1] + OFFSET], self.radius)
|
|
|
|
text_surface = font.render(str(self.points), False, (0, 0, 0))
|
|
|
|
screen.blit(text_surface, (
|
|
|
|
round(self.position[0] - text_surface.get_rect().width / 2),
|
|
|
|
round(self.position[1] - text_surface.get_rect().height / 2) + OFFSET
|
|
|
|
))
|
|
|
|
|
|
|
|
def is_point_inside(self, x: float, y: float) -> bool:
|
|
|
|
"""
|
|
|
|
Check if a given point is inside this circle (to handle click events)
|
|
|
|
:param x: point's x coordinate
|
|
|
|
:param y: point's y coordinate
|
|
|
|
"""
|
|
|
|
return (self.position[0] - x) ** 2 + (self.position[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
|
|
|
|
|
|
|
|
|
|
|
|
# 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())
|
|
|
|
|
|
|
|
# clear the screen
|
|
|
|
screen.fill((40, 46, 70))
|
|
|
|
game.tick(dt)
|
|
|
|
game.draw_everything(screen)
|
|
|
|
|
|
|
|
pygame.display.update()
|
|
|
|
|
|
|
|
|