|
|
|
@ -5,6 +5,8 @@ from dataclasses import dataclass, field
|
|
|
|
|
import time |
|
|
|
|
import numpy as np |
|
|
|
|
import typing |
|
|
|
|
import json |
|
|
|
|
import os |
|
|
|
|
from random import uniform, choice |
|
|
|
|
if typing.TYPE_CHECKING: |
|
|
|
|
from nptyping import NDArray |
|
|
|
@ -22,10 +24,21 @@ dt = 10
|
|
|
|
|
# load the font |
|
|
|
|
font = pygame.font.Font('orbitron-medium.otf', 15) |
|
|
|
|
font_large = pygame.font.Font('orbitron-medium.otf', 25) |
|
|
|
|
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 |
|
|
|
|
ENDGAME_BUTTON_RECT = (850, 15, 20, 20) |
|
|
|
|
BGCOLOR = (40, 46, 70) |
|
|
|
|
|
|
|
|
|
# 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)] |
|
|
|
|
# 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 |
|
|
|
@ -36,10 +49,19 @@ class 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 |
|
|
|
|
: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 |
|
|
|
|
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): |
|
|
|
|
""" |
|
|
|
@ -48,35 +70,101 @@ class Game:
|
|
|
|
|
""" |
|
|
|
|
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)) |
|
|
|
|
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): |
|
|
|
|
""" |
|
|
|
|
Process mouse click, check if the user clicked on a ball and process it |
|
|
|
|
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 |
|
|
|
|
""" |
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
""" |
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
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: |
|
|
|
@ -90,6 +178,9 @@ class Ball:
|
|
|
|
|
very sad and depressing |
|
|
|
|
:param radius: the radius of the ball on screen in pixels |
|
|
|
|
:param color: ball's color |
|
|
|
|
: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)])) |
|
|
|
@ -97,32 +188,54 @@ class Ball:
|
|
|
|
|
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) |
|
|
|
|
shape: str = 'circle' |
|
|
|
|
deviation_radius: float = 0 |
|
|
|
|
deviation_angle: float = 0 |
|
|
|
|
|
|
|
|
|
@classmethod |
|
|
|
|
def new_random(cls) -> Ball: |
|
|
|
|
radius, speed, color, points = choice(BALL_TYPES) |
|
|
|
|
radius, speed, color, points, shape, deviation_radius = 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)])) |
|
|
|
|
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 |
|
|
|
|
""" |
|
|
|
|
pygame.draw.circle(screen, self.color, [self.position[0], self.position[1] + OFFSET], self.radius) |
|
|
|
|
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, ( |
|
|
|
|
round(self.position[0] - text_surface.get_rect().width / 2), |
|
|
|
|
round(self.position[1] - text_surface.get_rect().height / 2) + OFFSET |
|
|
|
|
round(x - text_surface.get_rect().width / 2), |
|
|
|
|
round(y - text_surface.get_rect().height / 2) |
|
|
|
|
)) |
|
|
|
|
|
|
|
|
|
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: |
|
|
|
|
""" |
|
|
|
|
Check if a given point is inside this circle (to handle click events) |
|
|
|
|
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 |
|
|
|
|
""" |
|
|
|
|
return (self.position[0] - x) ** 2 + (self.position[1] - y) ** 2 <= self.radius ** 2 |
|
|
|
|
return (self.position_with_shift()[0] - x) ** 2 + (self.position_with_shift()[1] - y) ** 2 <= self.radius ** 2 |
|
|
|
|
|
|
|
|
|
def tick(self, dt: float): |
|
|
|
|
""" |
|
|
|
@ -142,6 +255,7 @@ class Ball:
|
|
|
|
|
if self.position[1] >= H - self.radius: |
|
|
|
|
self.position[1] = H - self.radius |
|
|
|
|
self.velocity[1] *= -1 |
|
|
|
|
self.deviation_angle += dt * 2 * np.pi / 2000 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# create the display surface object |
|
|
|
@ -165,9 +279,11 @@ while True:
|
|
|
|
|
sys.exit(0) |
|
|
|
|
if event.type == pygame.MOUSEBUTTONUP: |
|
|
|
|
game.process_click(*pygame.mouse.get_pos()) |
|
|
|
|
if event.type == pygame.KEYDOWN: |
|
|
|
|
game.process_keypress(event) |
|
|
|
|
|
|
|
|
|
# clear the screen |
|
|
|
|
screen.fill((40, 46, 70)) |
|
|
|
|
screen.fill(BGCOLOR) |
|
|
|
|
game.tick(dt) |
|
|
|
|
game.draw_everything(screen) |
|
|
|
|
|
|
|
|
|