Files
tetri5/tetris/entity.py
2021-06-14 14:17:05 -04:00

405 lines
16 KiB
Python

import copy
import random
import pygame
from typing import List, Tuple
from pygame import mixer
from tetris.util import ConfigurationManager
from tetris.util import Controller
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from tetris.game import Game
"""
TODO description
"""
class Entity:
def __init__(self, points: Tuple, color: str, border_color: str = None):
self.points = points
self.color = color
self.border_color = border_color
self.elapsed_time = 0
def update(self, elapsed_time: int) -> None:
self.elapsed_time += elapsed_time
def draw(self, surface: pygame.Surface) -> None:
tile_size = ConfigurationManager.get("engine", "tile-size")
for square in self.points:
pygame.draw.polygon(surface, pygame.Color(self.color), square, 0)
if self.border_color:
pygame.draw.polygon(surface, pygame.Color(self.border_color), square, max(tile_size // 6, 1))
def collide(self, entity: "Entity") -> bool: # TODO figure out how to do type hint for entity param of type Entity
for square_one in self.points:
for square_two in entity.points:
for i in range(4): # 4 vertices
if square_one[i][0] == square_two[i][0] and square_one[i][1] == square_two[i][1]:
return True
return False
def _collide(self, points: List) -> bool:
for square_one in self.points:
for square_two in points:
for i in range(4): # 4 vertices
if square_one[i][0] == square_two[i][0] and square_one[i][1] == square_two[i][1]:
return True
return False
"""
TODO description
"""
class Well(Entity):
WIDTH = 10 # standard tetris well width, should not be changed
HEIGHT = 20 # standard tetris well height, should not be changed
def __init__(self, position: Tuple, color: str, border_color: str):
super().__init__(self._get_points(position), color, border_color)
def _get_points(self, position: Tuple) -> List:
tile_size = ConfigurationManager.get("engine", "tile-size")
shape = []
for i in range(self.WIDTH + 2):
for j in range(self.HEIGHT + 2):
if i == 0 or i == self.WIDTH + 1:
shape.append(((i, j), (i + 1, j), (i + 1, j + 1), (i, j + 1)))
elif j == 0 or j == self.HEIGHT + 1:
shape.append(((i, j), (i + 1, j), (i + 1, j + 1), (i, j + 1)))
points = []
for square in shape:
sub_points = []
for vertex in square:
point = [vertex[0] * tile_size + position[0], vertex[1] * tile_size + position[1]]
sub_points.append(point)
points.append(sub_points)
return points
'''
For information on the Tetris piece Tetromino go here:
https://tetris.fandom.com/wiki/Tetromino
'''
class Piece(Entity):
def __init__(self, shape: Tuple, position: Tuple, color: str, inner_border_color: str, border_color: str):
super().__init__(self._get_points(shape, position), color, border_color)
self.inner_border_color = inner_border_color
self.center = self._get_center(shape, position)
self.piece_set_sound = mixer.Channel(2)
self.previous_points = None
self.previous_center = None
# Gravity
self.gravity_time = ConfigurationManager.get("engine", "piece-gravity-time")
self.current_gravity_time = 0
self.applying_gravity = True
# Set
self.set_time = ConfigurationManager.get("engine", "piece-set-time")
self.current_set_time = 0
self.applying_set = False
def update(self, elapsed_time: int, game: "Game") -> None:
super().update(elapsed_time)
tile_size = ConfigurationManager.get("engine", "tile-size")
if self.applying_gravity:
self.applying_gravity = self._apply_gravity(elapsed_time, game.well, game.stack)
self.applying_set = not self.applying_gravity
"""
For more information on the piece set logic go here:
https://strategywiki.org/wiki/Tetris/Features#Lock_delay
"""
if self.applying_set:
self.applying_set = self._apply_set(elapsed_time, game)
self.applying_gravity = not self.applying_set
# handle rotation, left and right movement
if Controller.key_down(pygame.K_SPACE):
self.rotate()
if game.well and self.collide(game.well) or game.stack and self.collide(game.stack):
self.revert()
if Controller.key_down(pygame.K_LEFT):
self.move((-tile_size, 0))
if game.well and self.collide(game.well) or game.stack and self.collide(game.stack):
self.revert()
if Controller.key_down(pygame.K_RIGHT):
self.move((tile_size, 0))
if game.well and self.collide(game.well) or game.stack and self.collide(game.stack):
self.revert()
# handle soft drop movement and gravity based on level
gravity_time = ConfigurationManager.get("engine", "piece-gravity-time")
set_time = ConfigurationManager.get("engine", "piece-set-time")
gravity_increase = ConfigurationManager.get("engine", "piece-gravity-increase")
if Controller.key_pressed(pygame.K_DOWN):
self.gravity_time = max(10, (gravity_time - (game.get_level() * gravity_increase)) // 10)
self.set_time = max(10, set_time // 10)
if not Controller.key_pressed(pygame.K_DOWN):
self.gravity_time = gravity_time - (game.get_level() * gravity_increase)
self.set_time = set_time
def draw(self, surface: pygame.Surface, well: Well, stack: "Stack") -> None:
tile_size = ConfigurationManager.get("engine", "tile-size")
# ghost piece
for square in self._get_ghost_piece_points(well, stack):
pygame.draw.polygon(surface, pygame.Color("#FFFFFF"), square, max(tile_size // 6, 1)) # TODO add white to the yaml
super().draw(surface)
# inner border piece
for square in self.points:
if self.inner_border_color:
vertex_one = (square[0][0] + (tile_size // 10), square[0][1] + (tile_size // 10))
vertex_two = (square[1][0] - (tile_size // 10), square[1][1] + (tile_size // 10))
vertex_three = (square[2][0] - (tile_size // 10), square[2][1] - (tile_size // 10))
vertex_four = (square[3][0] + (tile_size // 10), square[3][1] - (tile_size // 10))
new_square = (vertex_one, vertex_two, vertex_three, vertex_four)
pygame.draw.polygon(surface, pygame.Color(self.inner_border_color), new_square, max(tile_size // 6, 1))
def move(self, vector: Tuple) -> None:
self.previous_points = copy.deepcopy(self.points)
self.previous_center = copy.deepcopy(self.center)
self.center[0] += vector[0]
self.center[1] += vector[1]
for square in self.points:
for vertex in square:
vertex[0] += vector[0]
vertex[1] += vector[1]
'''
For more information on a rotation of a piece go here:
https://gamedev.stackexchange.com/questions/17974/how-to-rotate-blocks-in-tetris
'''
def rotate(self) -> None:
self.previous_points = copy.deepcopy(self.points)
self.previous_center = copy.deepcopy(self.center)
new_points = []
for square in self.points:
for vertex in square:
h = vertex[0] - self.center[0]
k = vertex[1] - self.center[1]
vertex[0] = (k * -1) + self.center[0]
vertex[1] = h + self.center[1]
new_points.append([square[-1]] + square[0:-1])
self.points = new_points
def revert(self) -> None:
if self.previous_points and self.previous_center:
self.points = self.previous_points
self.center = self.previous_center
def _get_points(self, shape: Tuple, position: Tuple) -> List:
tile_size = ConfigurationManager.get("engine", "tile-size")
points = []
for square in shape[:-1]:
sub_points = []
for vertex in square:
point = [vertex[0] * tile_size + position[0], vertex[1] * tile_size + position[1]]
sub_points.append(point)
points.append(sub_points)
return points
def _get_center(self, shape: Tuple, position: Tuple) -> List:
tile_size = ConfigurationManager.get("engine", "tile-size")
center = shape[-1]
# cast to int and avoid exception from pygame (center can be a floating point)
return [int(center[0] * tile_size + position[0]), int(center[1] * tile_size + position[1])]
def _apply_gravity(self, elapsed_time: int, well: Well, stack: "Stack") -> bool:
tile_size = ConfigurationManager.get("engine", "tile-size")
self.current_gravity_time += elapsed_time
if self.current_gravity_time >= self.gravity_time:
self.current_gravity_time = 0
if not self._entity_is_below(well) and not self._entity_is_below(stack):
self.move((0, tile_size))
else:
return False
return True
def _apply_set(self, elapsed_time: int, game: "Game") -> bool:
self.current_set_time += elapsed_time
if self.current_set_time >= self.set_time:
self.current_set_time = 0
if self._entity_is_below(game.well) or self._entity_is_below(game.stack):
self._play_piece_set_sound()
game.stack.add_piece(self) # TODO do on tetris object level?
game.current_piece = None # TODO turn into a method
else:
return False
return True
def _mimic_move(self, vector: Tuple) -> List:
mimic_points = copy.deepcopy(self.points)
for square in mimic_points:
for vertex in square:
vertex[0] += vector[0]
vertex[1] += vector[1]
return mimic_points
def _play_piece_set_sound(self) -> None:
piece_set_sound_file = ConfigurationManager.get("sound", "piece-set")
self.piece_set_sound.play(mixer.Sound(piece_set_sound_file))
def _entity_is_below(self, entity: Entity) -> bool:
tile_size = ConfigurationManager.get("engine", "tile-size")
mimic_points = self._mimic_move((0, tile_size))
return entity and entity._collide(mimic_points)
def _get_ghost_piece_points(self, well: Well, stack: "Stack") -> List:
tile_size = ConfigurationManager.get("engine", "tile-size")
prior_points = []
current_points = copy.deepcopy(self.points)
while not well._collide(current_points) and not stack._collide(current_points):
prior_points = copy.deepcopy(current_points)
for square in current_points:
for vertex in square:
vertex[1] += 1 * tile_size
return prior_points
# shape attributes
I_SHAPE = (((0, 0), (1, 0), (1, 1), (0, 1)), ((1, 0), (2, 0), (2, 1), (1, 1)), ((2, 0), (3, 0), (3, 1), (2, 1)), ((3, 0), (4, 0), (4, 1), (3, 1)), (2, 0))
J_SHAPE = (((0, 0), (1, 0), (1, 1), (0, 1)), ((1, 0), (2, 0), (2, 1), (1, 1)), ((2, 0), (3, 0), (3, 1), (2, 1)), ((2, 1), (3, 1), (3, 2), (2, 2)), (1.5, 0.5))
L_SHAPE = (((0, 0), (1, 0), (1, 1), (0, 1)), ((1, 0), (2, 0), (2, 1), (1, 1)), ((2, 0), (3, 0), (3, 1), (2, 1)), ((0, 1), (1, 1), (1, 2), (0, 2)), (1.5, 0.5))
O_SHAPE = (((0, 0), (1, 0), (1, 1), (0, 1)), ((1, 0), (2, 0), (2, 1), (1, 1)), ((1, 1), (2, 1), (2, 2), (1, 2)), ((0, 1), (1, 1), (1, 2), (0, 2)), (1, 1))
S_SHAPE = (((0, 1), (1, 1), (1, 2), (0, 2)), ((1, 0), (2, 0), (2, 1), (1, 1)), ((1, 1), (2, 1), (2, 2), (1, 2)), ((2, 0), (3, 0), (3, 1), (2, 1)), (1.5, 0.5))
T_SHAPE = (((0, 0), (1, 0), (1, 1), (0, 1)), ((1, 0), (2, 0), (2, 1), (1, 1)), ((1, 1), (2, 1), (2, 2), (1, 2)), ((2, 0), (3, 0), (3, 1), (2, 1)), (1.5, 0.5))
Z_SHAPE = (((0, 0), (1, 0), (1, 1), (0, 1)), ((1, 0), (2, 0), (2, 1), (1, 1)), ((1, 1), (2, 1), (2, 2), (1, 2)), ((2, 1), (3, 1), (3, 2), (2, 2)), (1.5, 0.5))
"""
TODO description
"""
class Stack(Entity):
def __init__(self, color: str, border_color: str):
super().__init__([], color, border_color)
self.lines_completed_count = 0
self.line_completed_sound = mixer.Channel(1)
def update(self, elapsed_time: int, game: "Game") -> None:
super().update(elapsed_time)
lines_completed = self._complete_rows()
current_level = game.get_level()
points_per_lines_completed = ConfigurationManager.get("engine", "points-per-lines-completed")
game.score += points_per_lines_completed[lines_completed] * (current_level + 1)
self.lines_completed_count += lines_completed
def add_piece(self, piece: Piece) -> None:
self.points += piece.points
# TODO refactor into multiple functions
def _complete_rows(self) -> int:
squares_by_row = {}
for square in self.points:
top_left_vertex = square[0]
if top_left_vertex[1] not in squares_by_row:
squares_by_row[top_left_vertex[1]] = []
if square not in squares_by_row[top_left_vertex[1]]:
squares_by_row[top_left_vertex[1]].append(square)
squares_to_exclude = []
rows_completed = []
for key in squares_by_row:
if len(squares_by_row[key]) == Well.WIDTH:
squares_to_exclude += squares_by_row[key]
rows_completed.append(key)
if len(squares_to_exclude) == 0:
return 0
self._play_line_completed_sound()
tile_size = ConfigurationManager.get("engine", "tile-size")
new_points = []
for square in self.points:
if square not in squares_to_exclude:
for vertex in square:
distance_to_move = 0
for row_completed in rows_completed:
if vertex[1] <= row_completed:
distance_to_move += 1
vertex[1] += tile_size * distance_to_move
new_points.append(square)
self.points = new_points
return len(rows_completed)
def _play_line_completed_sound(self) -> None:
line_completed_sound_file = ConfigurationManager.get("sound", "row-completion")
self.line_completed_sound.play(mixer.Sound(line_completed_sound_file))
"""
TODO description
"""
class PieceGenerator:
_bucket = []
@classmethod
def get_piece(cls, position: Tuple) -> Piece:
if len(cls._bucket) == 0:
cls._generate_bucket()
base_color, inner_border_color, outer_border_color = cls._get_piece_color()
return Piece(cls._get_piece_shape(cls._bucket.pop()), position, base_color, inner_border_color, outer_border_color)
@classmethod
def _generate_bucket(cls) -> None:
piece_types = list(range(7))
while len(cls._bucket) != 7:
random_number = random.randint(0, 6 - len(cls._bucket))
cls._bucket.append(piece_types.pop(random_number))
def _get_piece_shape(piece_number: int) -> Tuple:
if piece_number == 0:
return Piece.I_SHAPE
if piece_number == 1:
return Piece.J_SHAPE
if piece_number == 2:
return Piece.L_SHAPE
if piece_number == 3:
return Piece.O_SHAPE
if piece_number == 4:
return Piece.S_SHAPE
if piece_number == 5:
return Piece.T_SHAPE
if piece_number == 6:
return Piece.Z_SHAPE
return None
def _get_piece_color() -> Tuple:
random_number = random.randint(1, 3)
base_color = ConfigurationManager.get("color", "piece-" + str(random_number))
inner_border_color = None if random_number != 3 else ConfigurationManager.get("color", "piece-inner-border-1")
outer_border_color = ConfigurationManager.get("color", "piece-outer-border-1")
return (base_color, inner_border_color, outer_border_color)