Fix config loading, speed thresholds and block selection; add tests, README, CLAUDE.md

This commit is contained in:
Anry Das
2026-10-14 08:22:43 +03:00
parent 85a233f33c
commit d36037c88e
13 changed files with 638 additions and 38 deletions
+5
View File
@@ -3,3 +3,8 @@
/other/
/.venv/
/.idea/
# added by git-init
__pycache__/
*.py[cod]
/build/
+32
View File
@@ -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.
+69
View File
@@ -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
<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:=-_
+69
View File
@@ -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 чи відсутній ключ зупиняють гру з повідомленням, де названо файл і ключ.
### 📱 Скріншоти
<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:=-_
+85
View File
@@ -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]]
]
}
}
}
}
+163
View File
@@ -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]]
]
}
}
}
}
-1
View File
@@ -161,4 +161,3 @@
}
}
}
}
+71
View File
@@ -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
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+70
View File
@@ -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
View File
@@ -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