diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..71c9e89 Binary files /dev/null and b/.coverage differ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..dcb1530 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python.analysis.typeCheckingMode": "basic", + "python.analysis.autoImportCompletions": true +} \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..22e8a03 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2023-present eval Nya <11857526-nexplorer-3e@users.noreply.gitlab.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/dist/tetris_pygame_hatch-0.0.1-py3-none-any.whl b/dist/tetris_pygame_hatch-0.0.1-py3-none-any.whl new file mode 100644 index 0000000..9a0a0ec Binary files /dev/null and b/dist/tetris_pygame_hatch-0.0.1-py3-none-any.whl differ diff --git a/dist/tetris_pygame_hatch-0.0.1.tar.gz b/dist/tetris_pygame_hatch-0.0.1.tar.gz new file mode 100644 index 0000000..4588eb8 Binary files /dev/null and b/dist/tetris_pygame_hatch-0.0.1.tar.gz differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..95ec3d8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,63 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "tetris-pygame-hatch" +description = '' +readme = "README.md" +requires-python = ">=3.7" +license = "MIT" +keywords = [] +authors = [ + { name = "eval Nya", email = "11857526-nexplorer-3e@users.noreply.gitlab.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = [ + "pygame" +] +dynamic = ["version"] + +[project.urls] +Documentation = "https://github.com/unknown/tetris-pygame-hatch#readme" +Issues = "https://github.com/unknown/tetris-pygame-hatch/issues" +Source = "https://github.com/unknown/tetris-pygame-hatch" + +[tool.hatch.version] +path = "tetris_pygame_hatch/__about__.py" + +[tool.hatch.envs.default] +dependencies = [ + "pytest", + "pytest-cov", +] +[tool.hatch.envs.default.scripts] +cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=tetris_pygame_hatch --cov=tests {args}" +no-cov = "cov --no-cov {args}" + +[[tool.hatch.envs.test.matrix]] +python = ["37", "38", "39", "310", "311"] + +[tool.coverage.run] +branch = true +parallel = true +omit = [ + "tetris_pygame_hatch/__about__.py", +] + +[tool.coverage.report] +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] diff --git a/scratch.py b/scratch.py new file mode 100644 index 0000000..1089e46 --- /dev/null +++ b/scratch.py @@ -0,0 +1,88 @@ +import pygame +import math +import itertools + +# Define constants +WHITE = (255, 255, 255) +BLACK = (0, 0, 0) +WIDTH, HEIGHT = 800, 600 +CENTER = (WIDTH // 2, HEIGHT // 2) + +# Define the tesseract vertices in 4D space +vertices = [-1, 1] +tesseract_vertices = [[x, y, z, w] for x in vertices for y in vertices + for z in vertices for w in vertices] + +# Define the edges between vertices +edges = [(n, n ^ 1) for n in range(16) for bit in range(4) if n & (1 << bit)] + +# Initialize Pygame +pygame.init() +screen = pygame.display.set_mode((WIDTH, HEIGHT)) +clock = pygame.time.Clock() +running = True + + +# Projection functions +def project_4D_to_3D(x, y, z, w, rotation_xy, rotation_xz, rotation_xw): + # Rotate in 4D space + xz = x * math.cos(rotation_xz) - z * math.sin(rotation_xz) + zx = z * math.cos(rotation_xz) + x * math.sin(rotation_xz) + xy = xz * math.cos(rotation_xy) - y * math.sin(rotation_xy) + yx = y * math.cos(rotation_xy) + xz * math.sin(rotation_xy) + xw = xy * math.cos(rotation_xw) - w * math.sin(rotation_xw) + wx = w * math.cos(rotation_xw) + xy * math.sin(rotation_xw) + + # 4D to 3D projection + distance = 2 + factor = distance / (distance + wx) + x3d = factor * yx + y3d = factor * zx + z3d = factor * w + return x3d, y3d, z3d + + +def project_3D_to_2D(x, y, z): + # 3D to 2D projection (ignoring z for simple orthographic projection) + return int(CENTER[0] + x * 100), int(CENTER[1] + y * 100) + + +# Main loop +angle_xy = angle_xz = angle_xw = 0.0 +while running: + # Handle events + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + # Update the rotation angles + angle_xy += 0.01 + angle_xz += 0.01 + angle_xw += 0.01 + + # Clear screen + screen.fill(BLACK) + + # Draw the tesseract + projected_2d_points: list = [] + for vertex in tesseract_vertices: + x, y, z, w = vertex + # Project the 4D point to 3D + x3d, y3d, z3d = project_4D_to_3D(x, y, z, w, angle_xy, angle_xz, + angle_xw) + # Project the 3D point to 2D and store it + projected_2d_points.append(project_3D_to_2D(x3d, y3d, z3d)) + + # Draw the edges + for edge in edges: + start_pos = projected_2d_points[edge[0]] + end_pos = projected_2d_points[edge[1]] + pygame.draw.line(screen, WHITE, start_pos, end_pos, 1) + + # Update display + pygame.display.flip() + + # Cap the frame rate + clock.tick(30) + +pygame.quit() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..802fac4 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2023-present eval Nya <11857526-nexplorer-3e@users.noreply.gitlab.com> +# +# SPDX-License-Identifier: MIT diff --git a/tetris_pygame_hatch/__about__.py b/tetris_pygame_hatch/__about__.py new file mode 100644 index 0000000..88ed54c --- /dev/null +++ b/tetris_pygame_hatch/__about__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2023-present eval Nya <11857526-nexplorer-3e@users.noreply.gitlab.com> +# +# SPDX-License-Identifier: MIT +__version__ = '0.0.1' diff --git a/tetris_pygame_hatch/__init__.py b/tetris_pygame_hatch/__init__.py new file mode 100644 index 0000000..ce568e6 --- /dev/null +++ b/tetris_pygame_hatch/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2023-present eval Nya <11857526-nexplorer-3e@users.noreply.gitlab.com> +# +# SPDX-License-Identifier: MIT + +from .app import App + +app = App() +app.run() \ No newline at end of file diff --git a/tetris_pygame_hatch/__pycache__/__init__.cpython-311.pyc b/tetris_pygame_hatch/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..e03c56a Binary files /dev/null and b/tetris_pygame_hatch/__pycache__/__init__.cpython-311.pyc differ diff --git a/tetris_pygame_hatch/__pycache__/app.cpython-311.pyc b/tetris_pygame_hatch/__pycache__/app.cpython-311.pyc new file mode 100644 index 0000000..82eb84e Binary files /dev/null and b/tetris_pygame_hatch/__pycache__/app.cpython-311.pyc differ diff --git a/tetris_pygame_hatch/__pycache__/settings.cpython-311.pyc b/tetris_pygame_hatch/__pycache__/settings.cpython-311.pyc new file mode 100644 index 0000000..d388e76 Binary files /dev/null and b/tetris_pygame_hatch/__pycache__/settings.cpython-311.pyc differ diff --git a/tetris_pygame_hatch/__pycache__/tetris.cpython-311.pyc b/tetris_pygame_hatch/__pycache__/tetris.cpython-311.pyc new file mode 100644 index 0000000..dfe88eb Binary files /dev/null and b/tetris_pygame_hatch/__pycache__/tetris.cpython-311.pyc differ diff --git a/tetris_pygame_hatch/__pycache__/tetromino.cpython-311.pyc b/tetris_pygame_hatch/__pycache__/tetromino.cpython-311.pyc new file mode 100644 index 0000000..a24d640 Binary files /dev/null and b/tetris_pygame_hatch/__pycache__/tetromino.cpython-311.pyc differ diff --git a/tetris_pygame_hatch/app.py b/tetris_pygame_hatch/app.py new file mode 100644 index 0000000..fb8d190 --- /dev/null +++ b/tetris_pygame_hatch/app.py @@ -0,0 +1,71 @@ +from typing import Any +import pygame as pg + +from .settings import * + + +class App: + def __init__(self) -> None: + from .tetris import Tetris + + # TODO: dynamic timeout + self.fall_timeout: Generator[int, Any, Any] = FALL_TIMEOUT_GENERATOR + self.paused = False + + pg.init() + pg.display.set_caption(CAPTION) + self.init_event_timer() + self.screen = pg.display.set_mode(FIELD_RES) + self.clock = pg.time.Clock() + self.tetris = Tetris(self) + + def init_event_timer(self): + # when timeout, set trigger to true to make tetromino fall + self.fall_trigger = False + self.fall_event = pg.USEREVENT + pg.time.set_timer(self.fall_event, next(self.fall_timeout)) + + def update(self): + self.tetris.update() + self.clock.tick(FPS) + + def draw(self): + ''' + main app frame drawer. This method only run once. + draw app background color + draw tetris grids + ''' + self.screen.fill(color=FIELD_COLOR, rect=(0, 0, *FIELD_RES)) + self.tetris.draw() # draw grid + pg.display.flip() + + def check_events(self): + ''' + Main event loop. + ''' + self.fall_trigger = False # fall_event set in set_event_timer + for event in pg.event.get(): + if event.type == pg.QUIT or ( + event.type == pg.KEYDOWN and event.key == pg.K_ESCAPE + or event.type == pg.KEYDOWN and event.key == pg.K_c + and pg.key.get_mods() & pg.KMOD_CTRL + ): # handle ctrl+c, https://stackoverflow.com/a/46506753/20419707 + pg.quit() + exit() + elif event.type == pg.KEYDOWN: + if event.key == pg.K_p: + self.paused = not self.paused + elif not self.paused: + self.tetris.control(event.key) + elif event.type == self.fall_event and not self.paused: + self.fall_trigger = True + pg.time.set_timer(self.fall_event, next(self.fall_timeout)) + + def run(self): + ''' + Main loop. + ''' + while True: + self.check_events() + self.update() + self.draw() diff --git a/tetris_pygame_hatch/settings.py b/tetris_pygame_hatch/settings.py new file mode 100644 index 0000000..2de7299 --- /dev/null +++ b/tetris_pygame_hatch/settings.py @@ -0,0 +1,68 @@ +from math import log +import random +from typing import Any, Generator, Tuple + +import pygame as pg + +vec = pg.math.Vector2 + +TETROMINO_COLORS: dict[str, str] # TODO pg.Color +TETROMINOES: dict[str, Tuple[tuple[int, int] | vec, tuple[int, int] | vec, + tuple[int, int] | vec, tuple[int, int] | vec]] +CAPTION: str = 'Tetris Pygame Demo' +FPS: float = 60 +FIELD_COLOR: Tuple[int, int, int] = (48, 39, 32) + +TILE_SIZE = 20 +FIELD_SIZE = FIELD_W, FIELD_H = 20, 30 +FIELD_RES = FIELD_W * TILE_SIZE, FIELD_H * TILE_SIZE +FALL_TIMEOUT_GENERATOR: Generator[int, Any, + Any] # millseconds, tetromino fall countdown + +MOVE_DIRECTIONS = {'l': vec(-1, 0), 'r': vec(1, 0), 'd': vec(0, 1)} + +# https://www.pygame.org/docs/ref/color_list.html +TETROMINO_COLORS = { + 'T': 'mediumorchid', + 'O': 'khaki1', + 'J': 'royalblue3', + 'L': 'orange', + 'I': 'cyan1', + 'S': 'seagreen2', + 'Z': 'indianred2', + '.': 'azure4', +} +TETROMINOES = { + 'T': ((0, 0), (-1, 0), (1, 0), (0, -1)), + 'O': ((0, 0), (0, -1), (-1, 0), (-1, -1)), + 'J': ((0, 0), (-1, 0), (0, -1), (0, -2)), + 'L': ((0, 0), (-1, 0), (-1, -1), (-1, -2)), + 'I': ((0, 0), (0, 1), (0, -1), (0, -2)), + 'S': ((0, 0), (-1, 0), (0, -1), (1, -1)), + 'Z': ((0, 0), (1, 0), (0, -1), (-1, -1)) +} + +__bag_count: int = 0 + + +def _7bag_picker() -> Generator[str, Any, None]: + global __bag_count + _7bag = list(TETROMINOES.keys()) + random.shuffle(_7bag) + while True: + for tetromino in _7bag: + yield tetromino + random.shuffle(_7bag) + if __bag_count: + __bag_count += 1 + + +def countdown_gen() -> Generator[int, Any, None]: + DEFAULT_COUNTDOWN: float = 1 * 1000 # millseconds + while True: + yield int(DEFAULT_COUNTDOWN - log(__bag_count + 1) * 10) + + +# define your generator which invoked by Tetris class. +TETROMINO_GENERATOR = _7bag_picker() +FALL_TIMEOUT_GENERATOR = countdown_gen() diff --git a/tetris_pygame_hatch/tetris.py b/tetris_pygame_hatch/tetris.py new file mode 100644 index 0000000..c80713a --- /dev/null +++ b/tetris_pygame_hatch/tetris.py @@ -0,0 +1,101 @@ +from typing import List, Optional +import pygame as pg +from tetris_pygame_hatch import tetromino + +from tetris_pygame_hatch.tetromino import Tetromino + +from .settings import * + + +class Tetris: + def __init__(self, app) -> None: + from .app import App + from .tetromino import Tetromino, Block + self.app: App + self.sprite_group: pg.sprite.Group + self.tetromino: Tetromino + self.blocks: List[List[Optional[Block]]] + + self.app = app + self.tetromino_shaper = TETROMINO_GENERATOR + # TODO Advanced sprites + # initialze before tetromino for dependency + self.sprite_group = pg.sprite.Group() + self.blocks = [[None for i in range(FIELD_W)] for j in range(FIELD_H)] + self.tetromino = Tetromino(self, next(self.tetromino_shaper)) + + def control(self, pressed_key): + ''' + Game control. Note that pause is in app check_events. + + Parameters + ---------- + pressed_key : int + pygame pressed_key + ''' + if pressed_key == pg.K_LEFT or pressed_key == pg.K_a: + self.tetromino.move('l') + elif pressed_key == pg.K_RIGHT or pressed_key == pg.K_d: + self.tetromino.move('r') + elif pressed_key == pg.K_DOWN or pressed_key == pg.K_s: + self.tetromino.move('d') + elif pressed_key == pg.K_w or pressed_key == pg.K_UP: + self.tetromino.rotate() + elif pressed_key == pg.K_SPACE: + # FIXME race condition maybe + while not self.tetromino.landed: + self.tetromino.move('d') + self.app.fall_trigger = True + + def remove_line(self, y: int) -> bool: + if all(self.blocks[y]): + for block_on_y in self.blocks[y]: + if block_on_y is not None: + block_on_y.dead = True + for i in range(y, 0, -1): + for j in range(FIELD_W): + block = self.blocks[i - 1][j] + if block is not None: + block.pos += vec(0, 1) + if self.blocks[i - 1][j] is not None: + self.blocks[i][j] = self.blocks[i - 1][j] + else: self.blocks[i][j] = None + return True + else: return False + + def draw_grid(self): + for x in range(FIELD_W): + for y in range(FIELD_H): + pg.draw.rect( + self.app.screen, 'black', + (x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE), 1) + + def post_tetromino_landed(self): + if self.tetromino.landed: + # save block status into self.blocks + # Note: after removal the affect line above the operation line would fell by one line. + # this would affect the detection of block removal. + affect_line: List[int] = [] + for block in self.tetromino.blocks: + x, y = tuple(map(int, block.pos)) + self.blocks[y][x] = block + affect_line.append(y) + for y in affect_line: + while self.remove_line(y): pass + self.tetromino = Tetromino(self, next(self.tetromino_shaper)) + + def update(self): + ''' + events to update, + mainly handle at every 'app.fall_timeout' seconds + ''' + if self.app.fall_trigger: + self.tetromino.update() + self.post_tetromino_landed() + + # update the tetromino location since it may changed by self.tetris.control in MainApp event handler + self.sprite_group.update() + + def draw(self): + self.draw_grid() + self.sprite_group.draw(self.app.screen) diff --git a/tetris_pygame_hatch/tetromino.py b/tetris_pygame_hatch/tetromino.py new file mode 100644 index 0000000..2e5bae3 --- /dev/null +++ b/tetris_pygame_hatch/tetromino.py @@ -0,0 +1,105 @@ +import random +import pygame as pg + +from .settings import * + +class Block(pg.sprite.Sprite): + def __init__(self, tetromino, pos, color) -> None: + from .tetromino import Tetromino + self.tetromino: Tetromino = tetromino + self.pos = vec(pos) + vec((FIELD_W//2, 0)) + self.dead = False + + super().__init__(tetromino.tetris.sprite_group) + self.image = pg.Surface([TILE_SIZE, TILE_SIZE]) + # TODO user custom border radius + pg.draw.rect(self.image, color, (1, 1, TILE_SIZE, TILE_SIZE), border_radius=TILE_SIZE//2-5) + self.rect = self.image.get_rect() + + def rotate(self, pivot: vec) -> vec: # because pygame rotates by origin, need to specify pivot + ''' + Rotate 90 degress with pivot as origin + + Parameters + ---------- + pivot : vec + origin port for block rotation. + + Returns + ------- + Vector2 + rotated position. + ''' + return (self.pos - pivot).rotate(90) + pivot + + def set_rect_pos(self): + self.rect.topleft = int(self.pos[0]) * TILE_SIZE, int(self.pos[1]) * TILE_SIZE + + def is_collide(self, dt: vec) -> bool: + x, y = tuple(map(int, self.pos + dt)) + if (0 <= x < FIELD_W and y < FIELD_H) and (y < 0 or not self.tetromino.tetris.blocks[y][x]): + return False + return True + + def update(self): + ''' + make pos update ease, should update in tetromino class though + ''' + if self.dead: + self.kill() + else: + self.set_rect_pos() + +class Tetromino: + def __init__(self, tetris, shape: str) -> None: + from .tetris import Tetris + self.tetris: Tetris = tetris + + self.shape = shape + self.color = TETROMINO_COLORS[shape] + self.blocks = [Block(self, pos, self.color) for pos in TETROMINOES[self.shape]] + self.landed = False + + # hack to display full block + for i in range(-min((pos_y for _pos_x, pos_y in TETROMINOES[self.shape])) - 1): + self.move('d') + min((float(i) for i in range(5))) + + def is_collide(self, dt: vec) -> bool: + ''' + check every block if is collided. + + Returns + ------- + bool + True if collide + ''' + return any((i.is_collide(dt) for i in self.blocks)) + + def rotate(self): + ''' + Rotate the tetromino if possible, say not collide. + ''' + rotatable = True + pivot_pos = self.blocks[0].pos + for i in self.blocks[1:]: + if i.is_collide(i.rotate(pivot_pos) - i.pos): + rotatable = False + break + if rotatable: + for i in self.blocks: i.pos = i.rotate(pivot_pos) + + def move(self, direction): + movevec = MOVE_DIRECTIONS[direction] + # landing check + if not self.is_collide(movevec): + for block in self.blocks: + block.pos += movevec + elif direction == 'd': + self.landed = True + + def update(self): + ''' + update function called every time need to fall a cell + ''' + self.move('d') \ No newline at end of file