This commit is contained in:
eval Nya 2024-01-19 16:39:03 +08:00
parent ef02e35aef
commit 703312b0ac
19 changed files with 524 additions and 0 deletions

BIN
.coverage Normal file

Binary file not shown.

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"python.analysis.typeCheckingMode": "basic",
"python.analysis.autoImportCompletions": true
}

9
LICENSE.txt Normal file
View File

@ -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.

Binary file not shown.

BIN
dist/tetris_pygame_hatch-0.0.1.tar.gz vendored Normal file

Binary file not shown.

63
pyproject.toml Normal file
View File

@ -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:",
]

88
scratch.py Normal file
View File

@ -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()

3
tests/__init__.py Normal file
View File

@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2023-present eval Nya <11857526-nexplorer-3e@users.noreply.gitlab.com>
#
# SPDX-License-Identifier: MIT

View File

@ -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'

View File

@ -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()

Binary file not shown.

Binary file not shown.

View File

@ -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()

View File

@ -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()

View File

@ -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)

View File

@ -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')