diff --git a/.gitignore b/.gitignore index c8d86f4..10cd8ca 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ /other/ /.venv/ /.idea/ + +# added by git-init +__pycache__/ +*.py[cod] +/build/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..13de4ea --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,32 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt # pygame +.venv/bin/python das_tetris_gui.py # run the game +.venv/bin/python -m unittest discover -s tests -v # all tests (headless, SDL dummy driver) +.venv/bin/python -m unittest tests.test_game.TetrisGameTest.test_clear_lines_scoring # single test +.venv/bin/pyinstaller das_tetris_gui.spec # build executable into dist/ (local spec, git-ignored; equivalent: pyinstaller --onefile das_tetris_gui.py) +``` + +Smoke-run headless: `SDL_VIDEODRIVER=dummy timeout -s KILL 5 .venv/bin/python das_tetris_gui.py`. Use SIGKILL: SDL turns SIGTERM into `pygame.QUIT`, which opens the "exit?" dialog and waits forever. + +## Architecture + +- `das_tetris_gui.py` – entry point. `TetrisGame` (field, falling piece, scoring, speed, input), `TetrisRenderer` (pygame drawing), `show_question_dialogue` (modal Yes/No loop that runs its own event loop), `main()` loop at 60 FPS. +- `tetris_config.py` – `load_config()` overwrites module-level globals (`KEY_CONFIG`, `COLORS`, `BLOCK_TYPE`, `STORE_WIDTH`, `BLOCK_SIZE`, `HIGH_SCORE_INC_SPEED`, …). The game module calls it **at import time** and copies most globals into its own names, but reads `cfg.HIGH_SCORE_INC_SPEED` live. The hard-coded `BlockType` enum in this file is only the source for `store_config()`; at runtime `BLOCK_TYPE` is rebuilt dynamically from JSON. +- `Serializer.py` – `EnumSerializer`: JSON ⇄ Python with `__type__` tags (`PygameKey` stored by name, `tuple`/`list`/`set`, `EnumClass`, `EnumInstance`). Used for `config.json`; block files are read with plain `json`. + +### Config files (`configs/`) +- `config.json`, `blocks.json`, `blocks_ADD.json` – local/working configs. If one is missing, `_000.json` (committed defaults) is used. Paths are resolved next to the script, or next to the executable when frozen. +- Blocks from `blocks.json` (`ALL_BLOCKS.members`) and `blocks_ADD.json` (`ADDITIONAL_BLOCKS.members`, optional) are merged; a later key overrides an earlier one. Each block: `name`, `color` `[r,g,b]`, `min_score` (block appears only once score reaches it; at least one must be 0), `shapes` – list of rotations (clockwise), each a 0/1 matrix drawn from the top-left. +- Field cells store `index in list(BLOCK_TYPE) + 1`, so block order matters for rendering. Blocks with identical values become Enum aliases and disappear from `list(BLOCK_TYPE)`. +- `high_score_inc_speed` (optional, default false): `fall_speed` is the drop interval in seconds, so `true` makes the game **slower** above 5000/10000/15000 points, `false` makes it faster. +- `tetris_config.store_config()` overwrites `config.json`/`blocks.json` from the hard-coded values – do not call it casually. + +### Misc +- `other/` (git-ignored) holds early prototypes and translations of other Tetris implementations; not used by the game. +- PyInstaller spec bundles no data files: the built executable needs a `configs/` folder beside it. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5aaaff1 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +## -=:dAs:=- Tetris +### _Classic Tetris on Python + pygame with configurable blocks, colors and controls_ + +[![Python](https://img.shields.io/badge/Python-3.12-green)](#) [![pygame](https://img.shields.io/badge/pygame-2.6.1-orange)](#) [![PyInstaller](https://img.shields.io/badge/PyInstaller-6.16-blue)](#) + +🇺🇦 [Українська версія](README_ua.md) + +### 💡 Purpose +A desktop Tetris game where the set of pieces, their colors, the unlock score for each piece, the field width, block size, colors and keys are described in JSON files – so the game can be extended with new shapes without touching the code. + +### 📃 Features +- [Gameplay](#Gameplay) – falling pieces, rotation, line clearing, score and lines counter, next-piece preview +- [Progressive pieces](#ProgressivePieces) – 23 shapes, new ones unlock as the score grows +- [Configuration](#Configuration) – keys, colors, field width, block size and speed mode in JSON +- Pause, restart and exit confirmation dialog (mouse or Y/N keys) + +### ⭐ Advantages +- **Data-driven shapes** – any new piece is a few lines of JSON: name, color, minimal score and a list of rotations. +- **Safe defaults** – committed `*_000.json` defaults are used automatically when local configs are absent. +- **Runs from anywhere** – configs are resolved next to the script or the built executable, not the current directory. +- **Clear config errors** – broken JSON or a missing key stops the game with a message naming the file and key. + +### 📱 Screenshots + + + + + + + + + +
GameLogo
Game fieldLogo
+ +### 📌 Using + +#### Gameplay +**_Standard Tetris rules on a 20-row field_** +- `←` / `→` – move, `↑` – rotate, `↓` – drop one row +- `P` – pause, `R` – restart, `Esc` – exit (with confirmation) +- Score for cleared lines at once: 1 – 100, 2 – 300, 3 – 500, 4 – 800 +- The next piece is always different from the current one + +#### Progressive pieces +**_The higher the score, the more varied the pieces_** +- 8 base shapes (`configs/blocks.json`): Square, Line, Line3 and L from the start; J from 900, S from 1000, Z and T from 2500 points +- 15 additional shapes (`configs/blocks_ADD.json`, optional): Dot, U, Corner, X, P, F and others, unlocked from 3500 up to 30000 points +- Above 5000, 10000 and 15000 points the falling speed changes + +#### Configuration +**_All settings live in `configs/`_** +- `config.json` – keys (pygame key names, e.g. `K_LEFT`), controls help text, colors, `store_width` (field width in blocks), `block_size` (pixels), `high_score_inc_speed` +- `high_score_inc_speed`: `false` – the game speeds up as the score grows, `true` – it slows down +- `blocks.json` / `blocks_ADD.json` – pieces: `name`, `color` `[r, g, b]`, `min_score`, `shapes` (list of clockwise rotations, 0/1 matrices); at least one piece must have `min_score` 0 +- If a local file is missing, its `*_000.json` default is used + +### 🚀 Running +Requirements: Python 3.12, pygame 2.6. +```shell +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/python das_tetris_gui.py # start the game +.venv/bin/python -m unittest discover -s tests -v # run tests +.venv/bin/pip install pyinstaller +.venv/bin/pyinstaller --onefile das_tetris_gui.py # build a single executable into dist/ +``` +The executable reads configs from a `configs/` folder located next to it. + +###### _Made by -=:dAs:=-_ diff --git a/README_ua.md b/README_ua.md new file mode 100644 index 0000000..e0c44dd --- /dev/null +++ b/README_ua.md @@ -0,0 +1,69 @@ +## -=:dAs:=- Tetris +### _Класичний тетріс на Python + pygame з налаштовуваними фігурами, кольорами та керуванням_ + +[![Python](https://img.shields.io/badge/Python-3.12-green)](#) [![pygame](https://img.shields.io/badge/pygame-2.6.1-orange)](#) [![PyInstaller](https://img.shields.io/badge/PyInstaller-6.16-blue)](#) + +🇬🇧 [English version](README.md) + +### 💡 Призначення +Десктопна гра тетріс, у якій набір фігур, їхні кольори, рахунок для появи кожної фігури, ширина поля, розмір блока, кольори та клавіші описані у JSON-файлах – тож гру можна розширювати новими фігурами, не змінюючи код. + +### 📃 Можливості +- [Ігровий процес](#Gameplay) – падіння фігур, обертання, очищення ліній, рахунок і лічильник ліній, попередній перегляд наступної фігури +- [Прогресивні фігури](#ProgressivePieces) – 23 фігури, нові відкриваються зі зростанням рахунку +- [Налаштування](#Configuration) – клавіші, кольори, ширина поля, розмір блока та режим швидкості в JSON +- Пауза, перезапуск і діалог підтвердження виходу (мишею або клавішами Y/N) + +### ⭐ Переваги +- **Фігури як дані** – нова фігура – це кілька рядків JSON: назва, колір, мінімальний рахунок і список поворотів. +- **Безпечні значення за замовчуванням** – якщо локальних конфігів немає, автоматично використовуються закомічені `*_000.json`. +- **Запуск з будь-якої теки** – конфіги шукаються поруч зі скриптом або зібраним виконуваним файлом, а не в поточній теці. +- **Зрозумілі помилки конфігурації** – зламаний JSON чи відсутній ключ зупиняють гру з повідомленням, де названо файл і ключ. + +### 📱 Скріншоти + + + + + + + + + +
ГраЛоготип
Ігрове полеЛоготип
+ +### 📌 Використання + +#### Ігровий процес
+**_Стандартні правила тетрісу на полі висотою 20 рядків_** +- `←` / `→` – рух, `↑` – поворот, `↓` – опустити на рядок +- `P` – пауза, `R` – перезапуск, `Esc` – вихід (з підтвердженням) +- Очки за одночасно очищені лінії: 1 – 100, 2 – 300, 3 – 500, 4 – 800 +- Наступна фігура завжди відрізняється від поточної + +#### Прогресивні фігури +**_Що вищий рахунок, то різноманітніші фігури_** +- 8 базових фігур (`configs/blocks.json`): квадрат, лінія, лінія-3 та L – одразу; J – з 900, S – з 1000, Z і T – з 2500 очок +- 15 додаткових фігур (`configs/blocks_ADD.json`, необов'язковий): точка, U, кут, X, P, F та інші – від 3500 до 30000 очок +- Після 5000, 10000 і 15000 очок змінюється швидкість падіння + +#### Налаштування +**_Усі налаштування – в теці `configs/`_** +- `config.json` – клавіші (назви клавіш pygame, напр. `K_LEFT`), текст підказки з керуванням, кольори, `store_width` (ширина поля в блоках), `block_size` (пікселі), `high_score_inc_speed` +- `high_score_inc_speed`: `false` – гра пришвидшується зі зростанням рахунку, `true` – сповільнюється +- `blocks.json` / `blocks_ADD.json` – фігури: `name`, `color` `[r, g, b]`, `min_score`, `shapes` (список поворотів за годинниковою стрілкою, матриці з 0/1); хоча б одна фігура повинна мати `min_score` 0 +- Якщо локального файлу немає, використовується відповідний `*_000.json` + +### 🚀 Запуск +Вимоги: Python 3.12, pygame 2.6. +```shell +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/python das_tetris_gui.py # запустити гру +.venv/bin/python -m unittest discover -s tests -v # запустити тести +.venv/bin/pip install pyinstaller +.venv/bin/pyinstaller --onefile das_tetris_gui.py # зібрати єдиний виконуваний файл у dist/ +``` +Виконуваний файл читає конфіги з теки `configs/`, розташованої поруч із ним. + +###### _Made by -=:dAs:=-_ diff --git a/configs/blocks.json b/configs/blocks.json new file mode 100644 index 0000000..f37eebd --- /dev/null +++ b/configs/blocks.json @@ -0,0 +1,85 @@ +{ + "ALL_BLOCKS": { + "__type__": "EnumClass", + "name": "BlockType", + "members": { + "O": { + "name": "Square", + "color": [255,255,0], + "min_score": 0, + "shapes": [ + [[1,1],[1,1]] + ] + }, + "I": { + "name": "Line", + "color": [0,255,255], + "min_score": 0, + "shapes": [ + [[1,1,1,1]], + [[1],[1],[1],[1]] + ] + }, + "I3": { + "name": "Line3", + "color": [55,180,210], + "min_score": 0, + "shapes": [ + [[1,1,1]], + [[1],[1],[1]] + ] + }, + "L": { + "name": "L-Shape", + "color": [255,0,255], + "min_score": 0, + "shapes": [ + [[1, 0], [1, 0], [1, 1]], + [[1, 1, 1], [1, 0, 0]], + [[1, 1], [0, 1], [0, 1]], + [[0, 0, 1], [1, 1, 1]] + ] + }, + "J": { + "name": "J-Shape", + "color": [0,0,255], + "min_score": 900, + "shapes": [ + [[0, 1], [0, 1], [1, 1]], + [[1, 0, 0], [1, 1, 1]], + [[1, 1], [1, 0], [1, 0]], + [[1, 1, 1], [0, 0, 1]] + ] + }, + "S": { + "name": "S-Shape", + "color": [0,255,0], + "min_score": 1000, + "shapes": [ + [[0, 1, 1], [1, 1, 0]], + [[1, 0], [1, 1], [0, 1]] + ] + }, + "Z": { + "name": "Z-Shape", + "color": [255,0,0], + "min_score": 2500, + "shapes": [ + [[1, 1, 0], [0, 1, 1]], + [[0, 1], [1, 1], [1, 0]] + ] + }, + "T": { + "name": "T-Shape", + "color": [255,255,255], + "min_score": 2500, + "shapes": [ + [[1, 1, 1], [0, 1, 0]], + [[1, 0], [1, 1], [1, 0]], + [[0, 1, 0], [1, 1, 1]], + [[0, 1], [1, 1], [0, 1]] + ] + } + } + } +} \ No newline at end of file diff --git a/configs/blocks_ADD.json b/configs/blocks_ADD.json new file mode 100644 index 0000000..a19fcc0 --- /dev/null +++ b/configs/blocks_ADD.json @@ -0,0 +1,163 @@ +{ + "ADDITIONAL_BLOCKS": { + "__type__": "EnumClass", + "name": "BlockType", + "members": { + "D": { + "name": "Dot", + "color": [255, 255, 100], + "min_score": 3500, + "shapes": [ + [[1]] + ] + }, + "U": { + "name": "U-Shape", + "color": [135, 135, 135], + "min_score": 4500, + "shapes": [ + [[1, 0, 1], [1, 1, 1]], + [[1, 1], [1, 0], [1, 1]], + [[1, 1, 1], [1, 0, 1]], + [[1, 1], [0, 1], [1, 1]] + ] + }, + "T1": { + "name": "T-Shape+", + "color": [0, 200, 255], + "min_score": 10000, + "shapes": [ + [[1, 1, 1], [0, 1, 0], [0, 1, 0]], + [[0, 0, 1], [1, 1, 1], [0, 0, 1]], + [[0, 1, 0], [0, 1, 0], [1, 1, 1]], + [[1, 0, 0], [1, 1, 1], [1, 0, 0]] + ] + }, + "U1": { + "name": "U-Shape+", + "color": [255, 150, 0], + "min_score": 10000, + "shapes": [ + [[1, 1, 1], [1, 0, 1], [1, 0, 1]], + [[1, 1, 1], [1, 0, 0], [1, 1, 1]], + [[1, 0, 1], [1, 0, 1], [1, 1, 1]], + [[1, 1, 1], [0, 0, 1], [1, 1, 1]] + ] + }, + "X": { + "name": "X-Shape", + "color": [50, 150, 100], + "min_score": 15000, + "shapes": [ + [[0, 1, 0], [1, 1, 1], [0, 1, 0]] + ] + }, + "Cn": { + "name": "Corner", + "color": [15, 180, 180], + "min_score": 5000, + "shapes": [ + [[1, 0], [1, 1]], + [[1, 1], [1, 0]], + [[1, 1], [0, 1]], + [[0, 1], [1, 1]] + ] + }, + "CN": { + "name": "Corner+", + "color": [15, 15, 180], + "min_score": 4000, + "shapes": [ + [[1, 0, 0], [1, 0, 0], [1, 1, 1]], + [[1, 1, 1], [1, 0, 0], [1, 0, 0]], + [[1, 1, 1], [0, 0, 1], [0, 0, 1]], + [[0, 0, 1], [0, 0, 1], [1, 1, 1]] + ] + }, + "W": { + "name": "Dabl", + "color": [180, 25, 230], + "min_score": 15000, + "shapes": [ + [[1, 0, 0], [1, 1, 0], [0, 1, 1]], + [[0, 1, 1], [1, 1, 0], [1, 0, 0]], + [[1, 1, 0], [0, 1, 1], [0, 0, 1]], + [[0, 0, 1], [0, 1, 1], [1, 1, 0]] + ] + }, + "Z1": { + "name": "Zet", + "color": [170, 25, 130], + "min_score": 25000, + "shapes": [ + [[1, 1, 0], [0, 1, 0], [0, 1, 1]], + [[0, 0, 1], [1, 1, 1], [1, 0, 0]] + ] + }, + "I2": { + "name": "Line2", + "color": [50, 25, 60], + "min_score": 25000, + "shapes": [ + [[1, 1]], + [[1], [1]] + ] + }, + "P": { + "name": "P", + "color": [240, 170, 40], + "min_score": 30000, + "shapes": [ + [[1, 1], [1, 1], [1, 0]], + [[1, 1, 1], [0, 1, 1]], + [[0, 1], [1, 1], [1, 1]], + [[1, 1, 0], [1, 1, 1]] + ] + }, + "F": { + "name": "F", + "color": [135, 110, 65], + "min_score": 30000, + "shapes": [ + [[0, 1, 1], [1, 1, 0], [0, 1, 0]], + [[0, 1, 0], [1, 1, 1], [0, 0, 1]], + [[0, 1, 0], [0, 1, 1], [1, 1, 0]], + [[1, 0, 0], [1, 1, 1], [0, 1, 0]] + ] + }, + "S1": { + "name": "S1", + "color": [240, 110, 20], + "min_score": 30000, + "shapes": [ + [[1, 0], [1, 0], [1, 1], [0, 1]], + [[0, 1, 1, 1], [1, 1, 0, 0]], + [[1, 0], [1, 1], [0, 1], [0, 1]], + [[0, 0, 1, 1], [1, 1, 1, 0]] + ] + }, + "L1": { + "name": "L1", + "color": [45, 95, 175], + "min_score": 30000, + "shapes": [ + [[1, 0], [1, 0], [1, 0], [1, 1]], + [[1, 1, 1, 1], [1, 0, 0, 0]], + [[1, 1], [0, 1], [0, 1], [0, 1]], + [[0, 0, 0, 1], [1, 1, 1, 1]] + ] + }, + "F1": { + "name": "F1", + "color": [240, 230, 60], + "min_score": 0, + "shapes": [ + [[0, 1], [1, 1], [0, 1], [0, 1]], + [[0, 0, 1, 0], [1, 1, 1, 1]], + [[1, 0], [1, 0], [1, 1], [1, 0]], + [[1, 1, 1, 1], [0, 1, 0, 0]] + ] + } + } + } +} \ No newline at end of file diff --git a/configs/blocks_ADD_000.json b/configs/blocks_ADD_000.json index 4027285..479f158 100644 --- a/configs/blocks_ADD_000.json +++ b/configs/blocks_ADD_000.json @@ -161,4 +161,3 @@ } } } -} \ No newline at end of file diff --git a/configs/config.json b/configs/config.json new file mode 100644 index 0000000..02eff90 --- /dev/null +++ b/configs/config.json @@ -0,0 +1,71 @@ +{ + "CONFIG": { + "keys": { + "left": { + "__type__": "PygameKey", + "name": "K_LEFT" + }, + "right": { + "__type__": "PygameKey", + "name": "K_RIGHT" + }, + "rotate": { + "__type__": "PygameKey", + "name": "K_UP" + }, + "drop": { + "__type__": "PygameKey", + "name": "K_DOWN" + }, + "pause": { + "__type__": "PygameKey", + "name": "K_p" + }, + "restart": { + "__type__": "PygameKey", + "name": "K_r" + }, + "exit": { + "__type__": "PygameKey", + "name": "K_ESCAPE" + } + }, + "controls_text": { + "__type__": "list", + "items": [ + "Controls:", + "Left/Right Move", + "Up - Rotate", + "Dn - Drop", + "P - Pause", + "R - Restart", + "ESC - Exit" + ] + }, + "colors": { + "background": { + "__type__": "tuple", + "items": [20, 20, 40] + }, + "field": { + "__type__": "tuple", + "items": [30, 30, 60] + }, + "border": { + "__type__": "tuple", + "items": [100, 100, 150] + }, + "text": { + "__type__": "tuple", + "items": [255, 255, 255] + }, + "game_over": { + "__type__": "tuple", + "items": [255, 0, 0] + } + }, + "store_width": 10, + "block_size": 30, + "high_score_inc_speed": true + } +} \ No newline at end of file diff --git a/das_tetris_gui.py b/das_tetris_gui.py index 77cbb57..a2edae8 100644 --- a/das_tetris_gui.py +++ b/das_tetris_gui.py @@ -85,8 +85,7 @@ class TetrisGame: else: self.current_block = self.next_block self.current_block_rotation = self.next_block_rotation - while self.current_block == self.next_block: - self.next_block = self.choice_random_block() + self.next_block = self.choice_random_block(exclude=self.current_block) self.next_block_rotation = 0 self.current_block_x = self.width // 2 - 2 @@ -97,11 +96,11 @@ class TetrisGame: self.current_block_rotation, self.current_block): self.game_over = True - def choice_random_block(self) -> BLOCK_TYPE: - block: BLOCK_TYPE = random.choice(list(BLOCK_TYPE)) - while self.get_block_min_score(block) > self.score: - block = random.choice(list(BLOCK_TYPE)) - return block + def choice_random_block(self, exclude: BLOCK_TYPE = None) -> BLOCK_TYPE: + """Choose a random block allowed at the current score, avoiding `exclude` when possible""" + allowed = [block for block in BLOCK_TYPE if self.get_block_min_score(block) <= self.score] + candidates = [block for block in allowed if block != exclude] or allowed + return random.choice(candidates) def get_block_shape(self, block_type: BLOCK_TYPE, rotation: int) -> List[List[int]]: """Get the shape matrix for a block at specific rotation""" @@ -220,20 +219,21 @@ class TetrisGame: elapsed = (current_ticks - self.last_time) / 1000.0 # Convert to seconds self.last_time = current_ticks + # Highest threshold first, otherwise only the first branch is ever reached if cfg.HIGH_SCORE_INC_SPEED: - if self.score > 5000: - self.fall_speed = 0.55 + if self.score > 15000: + self.fall_speed = 0.65 elif self.score > 10000: self.fall_speed = 0.6 - elif self.score > 15000: - self.fall_speed = 0.65 + elif self.score > 5000: + self.fall_speed = 0.55 else: - if self.score > 5000: - self.fall_speed = 0.45 + if self.score > 15000: + self.fall_speed = 0.35 elif self.score > 10000: self.fall_speed = 0.4 - elif self.score > 15000: - self.fall_speed = 0.35 + elif self.score > 5000: + self.fall_speed = 0.45 if not self.game_over and not self.paused: self.fall_time += elapsed diff --git a/images/logo_01_512x512.png b/images/logo_01_512x512.png new file mode 100644 index 0000000..5f85741 Binary files /dev/null and b/images/logo_01_512x512.png differ diff --git a/images/screenshot_game.png b/images/screenshot_game.png new file mode 100644 index 0000000..bd6fa1c Binary files /dev/null and b/images/screenshot_game.png differ diff --git a/tests/test_game.py b/tests/test_game.py new file mode 100644 index 0000000..2c6ad14 --- /dev/null +++ b/tests/test_game.py @@ -0,0 +1,70 @@ +import json +import os +import sys +import unittest +from pathlib import Path + +os.environ.setdefault('SDL_VIDEODRIVER', 'dummy') +os.environ.setdefault('SDL_AUDIODRIVER', 'dummy') +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +import das_tetris_gui as game_module # noqa: E402 +import tetris_config as cfg # noqa: E402 + + +class ConfigFilesTest(unittest.TestCase): + def test_all_config_files_are_valid_json(self): + for path in (ROOT / 'configs').glob('*.json'): + with self.subTest(path=path.name): + json.loads(path.read_text(encoding='utf-8')) + + def test_default_config_used_when_local_missing(self): + missing = os.path.join(cfg.CONFIG_DIR, 'nonexistent.json') + self.assertIsNone(cfg._config_path(missing)) + self.assertTrue(cfg._config_path(cfg.CONFIG_JSON).endswith('.json')) + + +class TetrisGameTest(unittest.TestCase): + def setUp(self): + self.game = game_module.TetrisGame(width=10) + + def test_random_block_respects_min_score(self): + self.game.score = 0 + for _ in range(200): + self.assertEqual(self.game.get_block_min_score(self.game.choice_random_block()), 0) + + def test_random_block_excludes_given_when_possible(self): + current = self.game.current_block + for _ in range(200): + self.assertNotEqual(self.game.choice_random_block(exclude=current), current) + + def test_random_block_with_single_allowed_block_does_not_hang(self): + only = next(b for b in game_module.BLOCK_TYPE if b.value['min_score'] == 0) + original = self.game.get_block_min_score + self.game.get_block_min_score = lambda b: 0 if b == only else 10 ** 9 + try: + self.assertEqual(self.game.choice_random_block(exclude=only), only) + finally: + self.game.get_block_min_score = original + + def test_clear_lines_scoring(self): + self.game.field[-1] = [1] * 10 + self.game.field[-2] = [1] * 10 + self.assertEqual(self.game.clear_lines(), 2) + self.assertEqual(self.game.score, 300) + self.assertEqual(self.game.field[-1], [0] * 10) + + def test_fall_speed_thresholds(self): + cases = {6000: (0.45, 0.55), 11000: (0.4, 0.6), 16000: (0.35, 0.65)} + for score, (normal, inc) in cases.items(): + for flag, expected in ((False, normal), (True, inc)): + with self.subTest(score=score, flag=flag): + cfg.HIGH_SCORE_INC_SPEED = flag + self.game.score = score + self.game.update(self.game.last_time) + self.assertEqual(self.game.fall_speed, expected) + + +if __name__ == '__main__': + unittest.main() diff --git a/tetris_config.py b/tetris_config.py index 072b4d9..321ab7e 100644 --- a/tetris_config.py +++ b/tetris_config.py @@ -6,9 +6,16 @@ from enum import Enum import pygame from Serializer import EnumSerializer -CONFIG_JSON = 'configs/config.json' -CONFIG_BLOCKS_JSON = 'configs/blocks.json' -CONFIG_ADD_BLOCKS_JSON = 'configs/blocks_ADD.json' +# Resolve configs next to the script (or next to the executable when frozen by PyInstaller), +# so the game does not depend on the current working directory +BASE_DIR = os.path.dirname(sys.executable if getattr(sys, 'frozen', False) else os.path.abspath(__file__)) +CONFIG_DIR = os.path.join(BASE_DIR, 'configs') + +CONFIG_JSON = os.path.join(CONFIG_DIR, 'config.json') +CONFIG_BLOCKS_JSON = os.path.join(CONFIG_DIR, 'blocks.json') +CONFIG_ADD_BLOCKS_JSON = os.path.join(CONFIG_DIR, 'blocks_ADD.json') +# Default configs ('*_000.json') are used when the local ones above are absent +DEFAULT_SUFFIX = '_000' @dataclasses.dataclass @@ -161,36 +168,66 @@ def store_config(): es.save_to_file(config_to_save, CONFIG_JSON) +def _config_path(path: str) -> str | None: + """Return the local config path, or its '*_000.json' default if the local one is absent""" + if os.path.isfile(path): + return path + root, ext = os.path.splitext(path) + default = root + DEFAULT_SUFFIX + ext + return default if os.path.isfile(default) else None + + +def _fail(message: str): + print(f'Config error: {message}') + sys.exit(10) + + +def _read_json(path: str) -> dict: + try: + with open(path, encoding='utf-8') as fd: + return json.load(fd) + except (OSError, json.JSONDecodeError) as e: + _fail(f'cannot read {path}: {e}') + + def load_config(): global KEY_CONFIG, COLORS, CONTROLS_TEXT, STORE_WIDTH, BLOCK_SIZE, BLOCK_TYPE, HIGH_SCORE_INC_SPEED es = EnumSerializer - if not os.path.isfile(CONFIG_JSON) or not os.path.isfile(CONFIG_JSON) : - print('No config files was found') - sys.exit(10) + config_path = _config_path(CONFIG_JSON) + blocks_path = _config_path(CONFIG_BLOCKS_JSON) + if config_path is None or blocks_path is None: + _fail(f'no config files were found in {CONFIG_DIR}') - config_data = es.load_from_file(CONFIG_JSON) - KEY_CONFIG = config_data['CONFIG']['keys'] - CONTROLS_TEXT = config_data['CONFIG']['controls_text'] - COLORS = config_data['CONFIG']['colors'] - STORE_WIDTH = config_data['CONFIG']['store_width'] - BLOCK_SIZE = config_data['CONFIG']['block_size'] - HIGH_SCORE_INC_SPEED = True if str(config_data['CONFIG']['high_score_inc_speed']).lower() == 'true' else False + _read_json(config_path) # validate JSON syntax with a clear message + try: + config = es.load_from_file(config_path)['CONFIG'] + KEY_CONFIG = config['keys'] + CONTROLS_TEXT = config['controls_text'] + COLORS = config['colors'] + STORE_WIDTH = config['store_width'] + BLOCK_SIZE = config['block_size'] + except (KeyError, TypeError) as e: + _fail(f'missing key {e} in {config_path}') + HIGH_SCORE_INC_SPEED = str(config.get('high_score_inc_speed', False)).lower() == 'true' - with open(CONFIG_BLOCKS_JSON) as block_fd: - blocks_data = json.load(block_fd) - - blocks0 = blocks_data['ALL_BLOCKS']['members'] + try: + blocks0 = _read_json(blocks_path)['ALL_BLOCKS']['members'] + except (KeyError, TypeError) as e: + _fail(f'missing key {e} in {blocks_path}') blocks1 = {} - if os.path.isfile(CONFIG_ADD_BLOCKS_JSON): - with open(CONFIG_ADD_BLOCKS_JSON) as block_fd: - blocks_data1 = json.load(block_fd) - blocks1 = blocks_data1['ADDITIONAL_BLOCKS']['members'] + add_blocks_path = _config_path(CONFIG_ADD_BLOCKS_JSON) + if add_blocks_path is not None: + try: + blocks1 = _read_json(add_blocks_path)['ADDITIONAL_BLOCKS']['members'] + except (KeyError, TypeError) as e: + _fail(f'missing key {e} in {add_blocks_path}') blocks = blocks0 | blocks1 + if not any(block['min_score'] <= 0 for block in blocks.values()): + _fail('at least one block must have min_score 0') BLOCK_TYPE = Enum('BlockType', blocks) - if __name__ == '__main__': pass