Fix config loading, speed thresholds and block selection; add tests, README, CLAUDE.md
This commit is contained in:
@@ -3,3 +3,8 @@
|
|||||||
/other/
|
/other/
|
||||||
/.venv/
|
/.venv/
|
||||||
/.idea/
|
/.idea/
|
||||||
|
|
||||||
|
# added by git-init
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
/build/
|
||||||
|
|||||||
@@ -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, `<name>_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.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
## -=:dAs:=- Tetris
|
||||||
|
### _Classic Tetris on Python + pygame with configurable blocks, colors and controls_
|
||||||
|
|
||||||
|
[](#) [](#) [](#)
|
||||||
|
|
||||||
|
🇺🇦 [Українська версія](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
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><img src="images/screenshot_game.png" width="300" alt="Game" /></td>
|
||||||
|
<td align="center"><img src="images/logo_02_256x256.png" width="200" alt="Logo" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><i>Game field</i></td>
|
||||||
|
<td align="center"><i>Logo</i></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### 📌 Using
|
||||||
|
|
||||||
|
#### Gameplay<a id='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<a id='ProgressivePieces' />
|
||||||
|
**_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<a id='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:=-_
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
## -=:dAs:=- Tetris
|
||||||
|
### _Класичний тетріс на Python + pygame з налаштовуваними фігурами, кольорами та керуванням_
|
||||||
|
|
||||||
|
[](#) [](#) [](#)
|
||||||
|
|
||||||
|
🇬🇧 [English version](README.md)
|
||||||
|
|
||||||
|
### 💡 Призначення
|
||||||
|
Десктопна гра тетріс, у якій набір фігур, їхні кольори, рахунок для появи кожної фігури, ширина поля, розмір блока, кольори та клавіші описані у JSON-файлах – тож гру можна розширювати новими фігурами, не змінюючи код.
|
||||||
|
|
||||||
|
### 📃 Можливості
|
||||||
|
- [Ігровий процес](#Gameplay) – падіння фігур, обертання, очищення ліній, рахунок і лічильник ліній, попередній перегляд наступної фігури
|
||||||
|
- [Прогресивні фігури](#ProgressivePieces) – 23 фігури, нові відкриваються зі зростанням рахунку
|
||||||
|
- [Налаштування](#Configuration) – клавіші, кольори, ширина поля, розмір блока та режим швидкості в JSON
|
||||||
|
- Пауза, перезапуск і діалог підтвердження виходу (мишею або клавішами Y/N)
|
||||||
|
|
||||||
|
### ⭐ Переваги
|
||||||
|
- **Фігури як дані** – нова фігура – це кілька рядків JSON: назва, колір, мінімальний рахунок і список поворотів.
|
||||||
|
- **Безпечні значення за замовчуванням** – якщо локальних конфігів немає, автоматично використовуються закомічені `*_000.json`.
|
||||||
|
- **Запуск з будь-якої теки** – конфіги шукаються поруч зі скриптом або зібраним виконуваним файлом, а не в поточній теці.
|
||||||
|
- **Зрозумілі помилки конфігурації** – зламаний JSON чи відсутній ключ зупиняють гру з повідомленням, де названо файл і ключ.
|
||||||
|
|
||||||
|
### 📱 Скріншоти
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><img src="images/screenshot_game.png" width="300" alt="Гра" /></td>
|
||||||
|
<td align="center"><img src="images/logo_02_256x256.png" width="200" alt="Логотип" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><i>Ігрове поле</i></td>
|
||||||
|
<td align="center"><i>Логотип</i></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### 📌 Використання
|
||||||
|
|
||||||
|
#### Ігровий процес<a id='Gameplay' />
|
||||||
|
**_Стандартні правила тетрісу на полі висотою 20 рядків_**
|
||||||
|
- `←` / `→` – рух, `↑` – поворот, `↓` – опустити на рядок
|
||||||
|
- `P` – пауза, `R` – перезапуск, `Esc` – вихід (з підтвердженням)
|
||||||
|
- Очки за одночасно очищені лінії: 1 – 100, 2 – 300, 3 – 500, 4 – 800
|
||||||
|
- Наступна фігура завжди відрізняється від поточної
|
||||||
|
|
||||||
|
#### Прогресивні фігури<a id='ProgressivePieces' />
|
||||||
|
**_Що вищий рахунок, то різноманітніші фігури_**
|
||||||
|
- 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 очок змінюється швидкість падіння
|
||||||
|
|
||||||
|
#### Налаштування<a id='Configuration' />
|
||||||
|
**_Усі налаштування – в теці `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:=-_
|
||||||
@@ -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]]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,4 +161,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-15
@@ -85,8 +85,7 @@ class TetrisGame:
|
|||||||
else:
|
else:
|
||||||
self.current_block = self.next_block
|
self.current_block = self.next_block
|
||||||
self.current_block_rotation = self.next_block_rotation
|
self.current_block_rotation = self.next_block_rotation
|
||||||
while self.current_block == self.next_block:
|
self.next_block = self.choice_random_block(exclude=self.current_block)
|
||||||
self.next_block = self.choice_random_block()
|
|
||||||
self.next_block_rotation = 0
|
self.next_block_rotation = 0
|
||||||
|
|
||||||
self.current_block_x = self.width // 2 - 2
|
self.current_block_x = self.width // 2 - 2
|
||||||
@@ -97,11 +96,11 @@ class TetrisGame:
|
|||||||
self.current_block_rotation, self.current_block):
|
self.current_block_rotation, self.current_block):
|
||||||
self.game_over = True
|
self.game_over = True
|
||||||
|
|
||||||
def choice_random_block(self) -> BLOCK_TYPE:
|
def choice_random_block(self, exclude: BLOCK_TYPE = None) -> BLOCK_TYPE:
|
||||||
block: BLOCK_TYPE = random.choice(list(BLOCK_TYPE))
|
"""Choose a random block allowed at the current score, avoiding `exclude` when possible"""
|
||||||
while self.get_block_min_score(block) > self.score:
|
allowed = [block for block in BLOCK_TYPE if self.get_block_min_score(block) <= self.score]
|
||||||
block = random.choice(list(BLOCK_TYPE))
|
candidates = [block for block in allowed if block != exclude] or allowed
|
||||||
return block
|
return random.choice(candidates)
|
||||||
|
|
||||||
def get_block_shape(self, block_type: BLOCK_TYPE, rotation: int) -> List[List[int]]:
|
def get_block_shape(self, block_type: BLOCK_TYPE, rotation: int) -> List[List[int]]:
|
||||||
"""Get the shape matrix for a block at specific rotation"""
|
"""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
|
elapsed = (current_ticks - self.last_time) / 1000.0 # Convert to seconds
|
||||||
self.last_time = current_ticks
|
self.last_time = current_ticks
|
||||||
|
|
||||||
|
# Highest threshold first, otherwise only the first branch is ever reached
|
||||||
if cfg.HIGH_SCORE_INC_SPEED:
|
if cfg.HIGH_SCORE_INC_SPEED:
|
||||||
if self.score > 5000:
|
if self.score > 15000:
|
||||||
self.fall_speed = 0.55
|
self.fall_speed = 0.65
|
||||||
elif self.score > 10000:
|
elif self.score > 10000:
|
||||||
self.fall_speed = 0.6
|
self.fall_speed = 0.6
|
||||||
elif self.score > 15000:
|
elif self.score > 5000:
|
||||||
self.fall_speed = 0.65
|
self.fall_speed = 0.55
|
||||||
else:
|
else:
|
||||||
if self.score > 5000:
|
if self.score > 15000:
|
||||||
self.fall_speed = 0.45
|
self.fall_speed = 0.35
|
||||||
elif self.score > 10000:
|
elif self.score > 10000:
|
||||||
self.fall_speed = 0.4
|
self.fall_speed = 0.4
|
||||||
elif self.score > 15000:
|
elif self.score > 5000:
|
||||||
self.fall_speed = 0.35
|
self.fall_speed = 0.45
|
||||||
|
|
||||||
if not self.game_over and not self.paused:
|
if not self.game_over and not self.paused:
|
||||||
self.fall_time += elapsed
|
self.fall_time += elapsed
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -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()
|
||||||
+59
-22
@@ -6,9 +6,16 @@ from enum import Enum
|
|||||||
import pygame
|
import pygame
|
||||||
from Serializer import EnumSerializer
|
from Serializer import EnumSerializer
|
||||||
|
|
||||||
CONFIG_JSON = 'configs/config.json'
|
# Resolve configs next to the script (or next to the executable when frozen by PyInstaller),
|
||||||
CONFIG_BLOCKS_JSON = 'configs/blocks.json'
|
# so the game does not depend on the current working directory
|
||||||
CONFIG_ADD_BLOCKS_JSON = 'configs/blocks_ADD.json'
|
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
|
@dataclasses.dataclass
|
||||||
@@ -161,36 +168,66 @@ def store_config():
|
|||||||
es.save_to_file(config_to_save, CONFIG_JSON)
|
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():
|
def load_config():
|
||||||
global KEY_CONFIG, COLORS, CONTROLS_TEXT, STORE_WIDTH, BLOCK_SIZE, BLOCK_TYPE, HIGH_SCORE_INC_SPEED
|
global KEY_CONFIG, COLORS, CONTROLS_TEXT, STORE_WIDTH, BLOCK_SIZE, BLOCK_TYPE, HIGH_SCORE_INC_SPEED
|
||||||
es = EnumSerializer
|
es = EnumSerializer
|
||||||
|
|
||||||
if not os.path.isfile(CONFIG_JSON) or not os.path.isfile(CONFIG_JSON) :
|
config_path = _config_path(CONFIG_JSON)
|
||||||
print('No config files was found')
|
blocks_path = _config_path(CONFIG_BLOCKS_JSON)
|
||||||
sys.exit(10)
|
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)
|
_read_json(config_path) # validate JSON syntax with a clear message
|
||||||
KEY_CONFIG = config_data['CONFIG']['keys']
|
try:
|
||||||
CONTROLS_TEXT = config_data['CONFIG']['controls_text']
|
config = es.load_from_file(config_path)['CONFIG']
|
||||||
COLORS = config_data['CONFIG']['colors']
|
KEY_CONFIG = config['keys']
|
||||||
STORE_WIDTH = config_data['CONFIG']['store_width']
|
CONTROLS_TEXT = config['controls_text']
|
||||||
BLOCK_SIZE = config_data['CONFIG']['block_size']
|
COLORS = config['colors']
|
||||||
HIGH_SCORE_INC_SPEED = True if str(config_data['CONFIG']['high_score_inc_speed']).lower() == 'true' else False
|
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:
|
try:
|
||||||
blocks_data = json.load(block_fd)
|
blocks0 = _read_json(blocks_path)['ALL_BLOCKS']['members']
|
||||||
|
except (KeyError, TypeError) as e:
|
||||||
blocks0 = blocks_data['ALL_BLOCKS']['members']
|
_fail(f'missing key {e} in {blocks_path}')
|
||||||
|
|
||||||
blocks1 = {}
|
blocks1 = {}
|
||||||
if os.path.isfile(CONFIG_ADD_BLOCKS_JSON):
|
add_blocks_path = _config_path(CONFIG_ADD_BLOCKS_JSON)
|
||||||
with open(CONFIG_ADD_BLOCKS_JSON) as block_fd:
|
if add_blocks_path is not None:
|
||||||
blocks_data1 = json.load(block_fd)
|
try:
|
||||||
blocks1 = blocks_data1['ADDITIONAL_BLOCKS']['members']
|
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
|
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)
|
BLOCK_TYPE = Enum('BlockType', blocks)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
pass
|
pass
|
||||||
|
|||||||
Reference in New Issue
Block a user