MXVK Vulkan Framework 0.35.0
C++20 Vulkan rendering framework for practical 2D and 3D application development with SDL3.
Loading...
Searching...
No Matches
main.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2## @file main.py
3## @brief A small 2D Tetris clone made with @c mxvk_wrap sprites and fonts.
4## @details Uses the block and background artwork from @c examples/tetris/data.
5## The game is deliberately self-contained: it implements the board, seven
6## tetrominoes, rotation, line clearing, scoring, levels, and keyboard input.
7## @section tetris_controls Controls
8## - Left/Right: move.
9## - Up: rotate clockwise.
10## - Down: soft drop.
11## - Space: hard drop.
12## - R: start a new game after game over.
13## - Escape: quit.
14
15from __future__ import annotations
16
17from pathlib import Path
18import random
19import sys
20import time
21
22EXAMPLES_DIR = Path(__file__).resolve().parents[3]
23if str(EXAMPLES_DIR) not in sys.path:
24 sys.path.insert(0, str(EXAMPLES_DIR))
25
26import mxvk_wrap as mx
27
28BOARD_WIDTH = 10
29BOARD_HEIGHT = 20
30EMPTY = -1
31PIECES = (
32 ((0, 0), (1, 0), (2, 0), (3, 0)),
33 ((0, 0), (0, 1), (1, 1), (2, 1)),
34 ((2, 0), (0, 1), (1, 1), (2, 1)),
35 ((0, 0), (1, 0), (0, 1), (1, 1)),
36 ((1, 0), (2, 0), (0, 1), (1, 1)),
37 ((1, 0), (0, 1), (1, 1), (2, 1)),
38 ((0, 0), (1, 0), (1, 1), (2, 1)),
39)
40BLOCK_FILES = (
41 "block_ltblue.png", "block_dblue.png", "block_orange.png", "block_yellow.png",
42 "block_green.png", "block_purple.png", "block_red.png",
43)
44CLEAR_FLASH_DURATION = 0.54
45CLEAR_FLASH_INTERVAL = 0.09
46SIMULATION_STEP = 1.0 / 60.0
47MAX_FRAME_DELTA = 0.25
48
49
50class TetrisGame(mx.App):
51 ## @brief A playable 2D Tetris window using @c mxvk_wrap graphics.
52
53 def __init__(self) -> None:
54 ## @brief Load Tetris images, create the board, and start the first piece.
55 self.asset_dir = Path(__file__).resolve().parent / "data"
56 super().__init__("MXVK Wrap 2D Tetris", 960, 720, vsync=True)
57 self.font = None
58 try:
59 self.font = mx.Font(self._font_path(), 24)
60 self.background = mx.Sprite(self, self.asset_dir / "psychedelic_background.png", vertex_shader=str(self.asset_dir / "sprite.vert.spv"), fragment_shader=str(self.asset_dir / "background.frag.spv"))
61 self.background.enable_extended_uniforms()
62 self.blocks = [mx.Sprite(self, self.asset_dir / path) for path in BLOCK_FILES]
63 self.clear_block = mx.Sprite(self, self.asset_dir / "block_gray.png")
64 except Exception:
65 if self.font is not None:
66 self.font.close()
67 self.close()
68 raise
69 self.grid: list[list[int]] = []
70 self.cells: tuple[tuple[int, int], ...] = ()
71 self.piece_color = 0
72 self.next_color = 0
73 self.piece_x = 0
74 self.piece_y = 0
75 self.score = 0
76 self.lines = 0
77 self.level = 1
78 self.game_over = False
79 self.clearing_rows: tuple[int, ...] = ()
80 self.clear_started = 0.0
81 self.background_start_time = time.monotonic()
82 self.mouse_x = 0.0
83 self.mouse_y = 0.0
84 self.mouse_pressed = False
85 self.simulation_time = 0.0
86 self.fall_elapsed = 0.0
88 self.last_frame_time = time.monotonic()
89 self.reset()
90
91 def _font_path(self) -> Path:
92 ## @brief Return the supplied Tetris font path.
93 ## @return The TrueType font stored beside the Tetris block artwork.
94 path = self.asset_dir / "font.ttf"
95 if not path.is_file():
96 raise FileNotFoundError(f"missing Tetris font: {path}")
97 return path
98
99 def reset(self) -> None:
100 ## @brief Start a fresh game with an empty board and score.
101 self.grid = [[EMPTY for _ in range(BOARD_WIDTH)] for _ in range(BOARD_HEIGHT)]
102 self.score = 0
103 self.lines = 0
104 self.level = 1
105 self.game_over = False
106 self.clearing_rows = ()
107 self.clear_started = 0.0
108 self.simulation_time = 0.0
109 self.fall_elapsed = 0.0
110 self.frame_accumulator = 0.0
111 self.last_frame_time = time.monotonic()
112 self.next_color = random.randrange(len(PIECES))
113 self._new_piece()
114
115 def _new_piece(self) -> None:
116 ## @brief Select and spawn a random tetromino at the top of the board.
117 self.piece_color = self.next_color
118 self.next_color = random.randrange(len(PIECES))
119 self.cells = PIECES[self.piece_color]
120 self.piece_x = 3
121 self.piece_y = 0
122 if self._collides(self.piece_x, self.piece_y, self.cells):
123 self.game_over = True
124
125 def _collides(self, x: int, y: int, cells: tuple[tuple[int, int], ...]) -> bool:
126 ## @brief Return whether a piece position overlaps a wall, floor, or block.
127 ## @param x Candidate board X coordinate.
128 ## @param y Candidate board Y coordinate.
129 ## @param cells Tetromino cells relative to @p x and @p y.
130 ## @return True when the placement is invalid.
131 for cell_x, cell_y in cells:
132 board_x = x + cell_x
133 board_y = y + cell_y
134 if board_x < 0 or board_x >= BOARD_WIDTH or board_y >= BOARD_HEIGHT:
135 return True
136 if board_y >= 0 and self.grid[board_y][board_x] != EMPTY:
137 return True
138 return False
139
140 def _move(self, offset_x: int, offset_y: int) -> bool:
141 ## @brief Move the active piece when its target placement is valid.
142 ## @param offset_x Horizontal cell offset.
143 ## @param offset_y Vertical cell offset.
144 ## @return True when the piece moved.
145 if self.game_over or self._collides(self.piece_x + offset_x, self.piece_y + offset_y, self.cells):
146 return False
147 self.piece_x += offset_x
148 self.piece_y += offset_y
149 return True
150
151 def _rotate(self) -> None:
152 ## @brief Rotate the active piece clockwise, with simple wall kicks.
153 if self.game_over or self.piece_color == 3:
154 return
155 rotated = tuple((-cell_y, cell_x) for cell_x, cell_y in self.cells)
156 minimum_x = min(cell_x for cell_x, _ in rotated)
157 minimum_y = min(cell_y for _, cell_y in rotated)
158 rotated = tuple((cell_x - minimum_x, cell_y - minimum_y) for cell_x, cell_y in rotated)
159 for kick in (0, -1, 1, -2, 2):
160 if not self._collides(self.piece_x + kick, self.piece_y, rotated):
161 self.cells = rotated
162 self.piece_x += kick
163 return
164
165 def _lock_piece(self) -> None:
166 ## @brief Merge the active piece into the board and clear completed lines.
167 for cell_x, cell_y in self.cells:
168 board_y = self.piece_y + cell_y
169 if board_y >= 0:
170 self.grid[board_y][self.piece_x + cell_x] = self.piece_color
171 self.clearing_rows = tuple(index for index, row in enumerate(self.grid) if EMPTY not in row)
172 if self.clearing_rows:
174 return
175 self._new_piece()
176
177 def _finish_line_clear(self) -> None:
178 ## @brief Collapse flashing rows, award points, and spawn the next piece.
179 cleared = len(self.clearing_rows)
180 cleared_rows = set(self.clearing_rows)
181 self.grid = [[EMPTY for _ in range(BOARD_WIDTH)] for _ in range(cleared)] + [row for index, row in enumerate(self.grid) if index not in cleared_rows]
182 self.lines += cleared
183 self.score += (0, 100, 300, 500, 800)[cleared] * self.level
184 self.level = self.lines // 10 + 1
185 self.clearing_rows = ()
186 self.clear_started = 0.0
187 self._new_piece()
188
189 def _clear_flash_visible(self) -> bool:
190 ## @brief Return whether completed rows should show the flash texture.
191 return int((self.simulation_time - self.clear_started) / CLEAR_FLASH_INTERVAL) % 2 == 0
192
193 def _hard_drop(self) -> None:
194 ## @brief Drop the active piece to its lowest valid row and lock it.
195 if self.game_over:
196 return
197 distance = 0
198 while self._move(0, 1):
199 distance += 1
200 self.score += distance * 2
201 self._lock_piece()
202
203 def _fall_interval(self) -> float:
204 ## @brief Return the current automatic drop interval in seconds.
205 return max(0.08, 0.75 - (self.level - 1) * 0.06)
206
207 def _simulate_step(self) -> None:
208 ## @brief Advance one fixed-rate simulation step independent of rendering.
209 if self.clearing_rows:
210 if self.simulation_time - self.clear_started >= CLEAR_FLASH_DURATION:
211 self._finish_line_clear()
212 return
213 if self.game_over:
214 return
215 self.fall_elapsed += SIMULATION_STEP
216 while self.fall_elapsed >= self._fall_interval():
217 self.fall_elapsed -= self._fall_interval()
218 if not self._move(0, 1):
219 self._lock_piece()
220 return
221
222 def _update(self) -> None:
223 ## @brief Run fixed 60 Hz simulation steps to keep game timing frame-rate independent.
224 now = time.monotonic()
225 elapsed = min(now - self.last_frame_time, MAX_FRAME_DELTA)
226 self.last_frame_time = now
227 self.frame_accumulator += max(0.0, elapsed)
228 while self.frame_accumulator >= SIMULATION_STEP:
229 self.frame_accumulator -= SIMULATION_STEP
230 self.simulation_time += SIMULATION_STEP
231 self._simulate_step()
232
233 def on_event(self, event) -> None:
234 ## @brief Handle game controls from MXVK keyboard events.
235 ## @param event MXVK event received by @c App.
236 if event.mouse_motion:
237 self.mouse_x = event.x
238 self.mouse_y = event.y
239 return
240 if event.mouse_button_down or event.mouse_button_up:
241 self.mouse_x = event.x
242 self.mouse_y = event.y
243 self.mouse_pressed = event.mouse_button_down
244 return
245 if not event.key_down:
246 return
247 if event.key == mx.KEY_ESCAPE:
248 self.quit()
249 elif self.clearing_rows:
250 return
251 elif self.game_over:
252 if event.key == mx.KEY_R:
253 self.reset()
254 elif event.key == mx.KEY_LEFT:
255 self._move(-1, 0)
256 elif event.key == mx.KEY_RIGHT:
257 self._move(1, 0)
258 elif event.key == mx.KEY_DOWN:
259 if self._move(0, 1):
260 self.score += 1
261 else:
262 self._lock_piece()
263 elif event.key == mx.KEY_UP:
264 self._rotate()
265 elif event.key == mx.KEY_SPACE:
266 self._hard_drop()
267
268 def _draw_cell(self, color: int, board_x: int, board_y: int, left: int, top: int, cell_size: int) -> None:
269 ## @brief Queue one block sprite at a board location.
270 ## @param color Index into the loaded block sprites.
271 ## @param board_x Board column.
272 ## @param board_y Board row.
273 ## @param left Board left screen coordinate.
274 ## @param top Board top screen coordinate.
275 ## @param cell_size Block width and height in pixels.
276 self.blocks[color].draw(left + board_x * cell_size, top + board_y * cell_size, width=cell_size, height=cell_size)
277
278 def draw(self) -> None:
279 ## @brief Draw the background, playfield, active piece, and HUD each frame.
280 self._update()
281 width, height = self.swapchain_extent
282 if width <= 0 or height <= 0:
283 return
284 self.background.set_mouse(self.mouse_x, self.mouse_y, self.mouse_pressed)
285 self.background.set_uniform(0, 0.0, 0.0, float(width), float(height))
286 self.background.set_uniform(2, 0.0, time.monotonic() - self.background_start_time, 0.0, 0.0)
287 self.background.draw(0, 0, width=width, height=height)
288 cell_size = max(16, min((height - 100) // (BOARD_HEIGHT + 1), (width - 260) // (BOARD_WIDTH + 2)))
289 board_width = BOARD_WIDTH * cell_size
290 board_height = BOARD_HEIGHT * cell_size
291 left = max(24, (width - board_width) // 2 - 100)
292 top = max(50, (height - board_height) // 2)
293 for board_y, row in enumerate(self.grid):
294 for board_x, color in enumerate(row):
295 if color != EMPTY:
296 if board_y in self.clearing_rows and self._clear_flash_visible():
297 self.clear_block.draw(left + board_x * cell_size, top + board_y * cell_size, width=cell_size, height=cell_size)
298 else:
299 self._draw_cell(color, board_x, board_y, left, top, cell_size)
300 if not self.game_over and not self.clearing_rows:
301 for cell_x, cell_y in self.cells:
302 self._draw_cell(self.piece_color, self.piece_x + cell_x, self.piece_y + cell_y, left, top, cell_size)
303 panel_x = left + board_width + 28
304 self.draw_text("2D TETRIS", panel_x, top, self.font, mx.Color(255, 245, 180))
305 self.draw_text(f"Score {self.score}", panel_x, top + 54, self.font)
306 self.draw_text(f"Lines {self.lines}", panel_x, top + 88, self.font, mx.Color(180, 225, 255))
307 self.draw_text(f"Level {self.level}", panel_x, top + 122, self.font, mx.Color(180, 255, 190))
308 self.draw_text("Next", panel_x, top + 182, self.font, mx.Color(255, 245, 180))
309 preview_cell = max(12, cell_size * 3 // 5)
310 preview_cells = PIECES[self.next_color]
311 preview_min_x = min(cell_x for cell_x, _ in preview_cells)
312 preview_min_y = min(cell_y for _, cell_y in preview_cells)
313 for cell_x, cell_y in preview_cells:
314 self.blocks[self.next_color].draw(panel_x + (cell_x - preview_min_x) * preview_cell, top + 220 + (cell_y - preview_min_y) * preview_cell, width=preview_cell, height=preview_cell)
315 self.draw_text("Arrows: move / rotate", panel_x, top + 340, self.font)
316 self.draw_text("Space: hard drop", panel_x, top + 374, self.font)
317 if self.game_over:
318 self.draw_text("GAME OVER", left + cell_size, top + board_height // 2 - 24, self.font, mx.Color(255, 100, 100))
319 self.draw_text("Press R to restart", left + cell_size, top + board_height // 2 + 14, self.font, mx.Color(255, 255, 255))
320
321
322def main() -> None:
323 ## @brief Create and run the 2D Tetris application.
324 TetrisGame().run()
325
326
327if __name__ == "__main__":
328 main()
float clear_started
Start a fresh game with an empty board and score.
Definition main.py:80
bool mouse_pressed
Handle game controls from MXVK keyboard events.
Definition main.py:84
int next_color
Start a fresh game with an empty board and score.
Definition main.py:72
tuple cells
Select and spawn a random tetromino at the top of the board.
Definition main.py:70
float simulation_time
Start a fresh game with an empty board and score.
Definition main.py:85
str asset_dir
Load Tetris images, create the board, and start the first piece.
Definition main.py:55
bool _move(self, int offset_x, int offset_y)
Definition main.py:140
int piece_y
Select and spawn a random tetromino at the top of the board.
Definition main.py:74
None _draw_cell(self, int color, int board_x, int board_y, int left, int top, int cell_size)
Definition main.py:268
int piece_x
Select and spawn a random tetromino at the top of the board.
Definition main.py:73
bool _collides(self, int x, int y, tuple[tuple[int, int],...] cells)
Definition main.py:125
float fall_elapsed
Start a fresh game with an empty board and score.
Definition main.py:86
bool game_over
Start a fresh game with an empty board and score.
Definition main.py:78
int lines
Start a fresh game with an empty board and score.
Definition main.py:76
tuple clearing_rows
Start a fresh game with an empty board and score.
Definition main.py:79
float mouse_x
Handle game controls from MXVK keyboard events.
Definition main.py:82
float mouse_y
Handle game controls from MXVK keyboard events.
Definition main.py:83
float frame_accumulator
Start a fresh game with an empty board and score.
Definition main.py:87
int score
Start a fresh game with an empty board and score.
Definition main.py:75
int level
Start a fresh game with an empty board and score.
Definition main.py:77
last_frame_time
Start a fresh game with an empty board and score.
Definition main.py:88
list grid
Start a fresh game with an empty board and score.
Definition main.py:69
int piece_color
Select and spawn a random tetromino at the top of the board.
Definition main.py:71
None __init__(self)
A playable 2D Tetris window using mxvk_wrap graphics.
Definition main.py:53
Definition main.py:1