♟️ Free Chess Trainer That Drills the Skills Chess.com Won't Touch

:gear: One Python File Turns Your PC Into a Chess Training Lab

From board coordinates to grandmaster-level puzzles. One Python file. Zero subscriptions.

I’ve seen many chess courses and playgrounds — but almost none of them had a proper training simulator. And the few that did? Locked behind a subscription. What’s the point of chess theory without practice?

So I built one. Free, open-source, runs offline, no account needed. Every two weeks, a new simulator drops with new training modes — each one a new level of skill and difficulty.


🎯 What This Actually Does

One Python file (chess_trainer.py) gives you an 8×8 interactive board with 9 training modes — each one targets a different chess skill.

Think of it like a gym for your chess brain. Instead of just reading about how a bishop moves, you practice finding every square it can reach. Instead of memorizing coordinates from a book, you click them on a live board until it’s muscle memory.

The app shows you green for correct answers, red for mistakes, and yellow for squares you missed. Every task comes with a hint, an explanation, and a demo you can watch.

Feature What It Means
Single .py file Download one file, run it, done
No internet needed Works fully offline
RU/EN language Switch with one key press
Keyboard + mouse Click squares or use hotkeys
Board rotation Practice from both sides
Drag & drop pieces Set up any position in free mode
🧠 All 9 Training Modes — What Each One Trains
Mode What You Practice How It Works
COORDS Board orientation Find a specific square (like a5) by clicking it
COLOR Square color recognition Determine if a given square is light or dark
DIAGONALS Lines and rays Trace diagonals, ranks, and files for bishop/rook/queen
STARTING Opening position memory Place pieces in their correct starting spots
GEOMETRY Piece movement Find every square a piece can reach on an empty board
CONTROL Field awareness Mark all squares attacked by pieces in a given position
MENTAL Blind calculation Calculate a move sequence in your head, then verify
Move Learning Full piece movement All pieces including captures and blocked paths
FREE Sandbox Set up any position, experiment, no rules enforced

:light_bulb: The real trick: Start with COORDS until you can find any square in under 2 seconds. That one skill speeds up everything else — most beginners waste mental energy just locating squares.

⌨️ Controls Cheat Sheet
Key What It Does
SPACE Switch to next mode
H Toggle hints on/off
R Get a new task
D Show the correct answer (demo)
L Switch language (RU ↔ EN)
F11 Fullscreen
ESC Exit fullscreen
Left click Select / move a piece
Right click Remove a piece (edit modes)

Bottom panel lets you drag any piece onto the board for custom setups.

🖼️ Screenshots

📊 Why This vs Chess.com or Lichess

Chess.com and Lichess are great for playing games and solving puzzles. But neither one isolates specific skills the way a dedicated trainer does.

Skill Chess.com / Lichess This Simulator
Coordinate drilling Limited or behind paywall Dedicated mode, unlimited tasks
Square color recognition Not available Built-in mode
Piece geometry on empty board Not available Dedicated mode
Field control mapping Not available Full position, mark all attacked squares
Mental calculation practice Partial (puzzles) Dedicated blind-move mode with verification
Custom position sandbox Available Available (drag & drop)
Price Free tier limited / $99/yr premium $0 forever
Internet required Yes No

:light_bulb: Use them together. Drill fundamentals here, then play rated games on Lichess/Chess.com. The combo is stronger than either one alone.

🚀 How to Run It

You need: Python 3 installed on your computer (Windows, Mac, or Linux).

Step 1 — Download chess_trainer.py

Step 2 — Open a terminal / command prompt in the same folder

Step 3 — Run:

python chess_trainer.py

That’s it. Tkinter comes built into Python — no extra installs needed.

:light_bulb: Don’t have Python? Download it free from python.org. During install, check the box that says “Add Python to PATH” — this is the #1 mistake beginners make.

🗺️ What's Coming Next — The Roadmap

I plan to release about 100 chess simulators total. Each one covers a different level of knowledge and difficulty. New releases drop every 2 weeks.

Coming Up Focus
Strategy simulators Positional play, pawn structure, piece activity
Tactics trainers Forks, pins, skewers, discovered attacks
Defense modes Recognizing threats, finding only moves
Endgame drills King + pawn, rook endings, basic mates
Puzzle book mode Unlimited problems up to grandmaster level

Every simulator is a new file, a new level. Collect them all or pick what you need.


#!/usr/bin/env python3
"""Chess trainer with multiple exercise modes and a free-play board."""

from __future__ import annotations

import math
import random
import tkinter as tk
from datetime import date
from dataclasses import dataclass
from enum import Enum
from typing import Dict, List, Optional, Set, Tuple

FILES = "abcdefgh"
RANKS = "12345678"
SQUARES = [f"{f}{r}" for r in RANKS for f in FILES]

LIGHT = "#f0d9b5"
DARK = "#b58863"
SEL = "#4aa3ff"
GOOD = "#5cb85c"
BAD = "#d9534f"
HINT = "#f7e36a"
DEMO = "#7b61ff"

# Максимально реалистичная оценка числа уникальных задач для этой программы.
# Часть режимов имеет точное число вариантов, для сложных режимов берём верхнюю оценку.
COORD_TASKS = 64
COLOR_TASKS = 2
DIAGONAL_TASKS = 64
STARTING_TASKS = 12
GEOMETRY_TASKS = 64 * 5
# Верхняя оценка для режима контроля: расстановка 6 фигур на разных клетках,
# 16 вариантов фигур/цветов на клетку и выбор стороны для контроля.
CONTROL_TASKS_UPPER = (math.factorial(64) // math.factorial(58)) * (16 ** 6) * 2
# Верхняя оценка для мысленных перемещений: 4 фигуры, 64 старта, до 4 шагов при макс. ветвлении ферзя.
MENTAL_TASKS_UPPER = 4 * 64 * (27 ** 4)
# Пешки: 2 цвета * 8 файлов * 6 рангов * 4 комбинации взятий.
PAWN_TASKS_UPPER = 2 * 8 * 6 * 4
# Свободный режим не имеет конечного числа сценариев, поэтому здесь его не ограничиваем.
MAX_REAL_TASKS = (
    COORD_TASKS
    + COLOR_TASKS
    + DIAGONAL_TASKS
    + STARTING_TASKS
    + GEOMETRY_TASKS
    + CONTROL_TASKS_UPPER
    + MENTAL_TASKS_UPPER
    + PAWN_TASKS_UPPER
)

PIECE_SYMBOLS = {
    "wK": "♔", "wQ": "♕", "wR": "♖", "wB": "♗", "wN": "♘", "wP": "♙",
    "bK": "♚", "bQ": "♛", "bR": "♜", "bB": "♝", "bN": "♞", "bP": "♟",
}

START_BOARD = {
    **{f"{f}2": "wP" for f in FILES},
    **{f"{f}7": "bP" for f in FILES},
    "a1": "wR", "h1": "wR", "a8": "bR", "h8": "bR",
    "b1": "wN", "g1": "wN", "b8": "bN", "g8": "bN",
    "c1": "wB", "f1": "wB", "c8": "bB", "f8": "bB",
    "d1": "wQ", "d8": "bQ", "e1": "wK", "e8": "bK",
}


class Mode(Enum):
    COORDS = "Координаты"
    COLOR = "Цвет клетки"
    DIAGONALS = "Диагонали"
    STARTING = "Расстановка фигур"
    GEOMETRY = "Геометрия фигур"
    CONTROL = "Контроль поля"
    MENTAL = "Мысленные перемещения"
    PAWN = "Обучение всем ходам"
    FREE = "Свободный режим"


@dataclass
class Task:
    number: int
    prompt: str
    expected: Set[str]
    multi: bool
    board: Optional[Dict[str, str]] = None
    hint: str = ""
    explain: str = ""
    target_piece: str = ""
    control_side: str = ""
    required_board: Optional[Dict[str, str]] = None
    demo_path: Optional[List[str]] = None
    challenge_path: Optional[List[str]] = None
    piece_name: str = ""


def sq_to_xy(square: str) -> Tuple[int, int]:
    x = FILES.index(square[0])
    y = 7 - RANKS.index(square[1])
    return x, y


def xy_to_sq(x: int, y: int) -> str:
    return f"{FILES[x]}{RANKS[7-y]}"


def on_board(x: int, y: int) -> bool:
    return 0 <= x < 8 and 0 <= y < 8


def square_color(square: str) -> str:
    x, y = sq_to_xy(square)
    return "light" if (x + y) % 2 == 0 else "dark"


def ray_moves(x: int, y: int, dirs: List[Tuple[int, int]], board: Dict[str, str], side: str) -> Set[str]:
    out: Set[str] = set()
    for dx, dy in dirs:
        cx, cy = x + dx, y + dy
        while on_board(cx, cy):
            sq = xy_to_sq(cx, cy)
            occ = board.get(sq)
            if occ:
                if occ[0] != side:
                    out.add(sq)
                break
            out.add(sq)
            cx += dx
            cy += dy
    return out


def piece_attacks(piece: str, square: str, board: Optional[Dict[str, str]] = None) -> Set[str]:
    board = board or {}
    side, kind = piece[0], piece[1]
    x, y = sq_to_xy(square)
    out: Set[str] = set()

    if kind == "N":
        for dx, dy in [(-2, -1), (-2, 1), (-1, -2), (-1, 2), (1, -2), (1, 2), (2, -1), (2, 1)]:
            nx, ny = x + dx, y + dy
            if on_board(nx, ny):
                sq = xy_to_sq(nx, ny)
                if board.get(sq, "").startswith(side):
                    continue
                out.add(sq)
    elif kind == "K":
        for dx in [-1, 0, 1]:
            for dy in [-1, 0, 1]:
                if dx == dy == 0:
                    continue
                nx, ny = x + dx, y + dy
                if on_board(nx, ny):
                    sq = xy_to_sq(nx, ny)
                    if board.get(sq, "").startswith(side):
                        continue
                    out.add(sq)
    elif kind == "B":
        out |= ray_moves(x, y, [(-1, -1), (-1, 1), (1, -1), (1, 1)], board, side)
    elif kind == "R":
        out |= ray_moves(x, y, [(-1, 0), (1, 0), (0, -1), (0, 1)], board, side)
    elif kind == "Q":
        out |= ray_moves(x, y, [(-1, -1), (-1, 1), (1, -1), (1, 1), (-1, 0), (1, 0), (0, -1), (0, 1)], board, side)
    elif kind == "P":
        d = 1 if side == "w" else -1
        for dx in (-1, 1):
            nx, ny = x + dx, y - d
            if on_board(nx, ny):
                out.add(xy_to_sq(nx, ny))
    return out


def pawn_moves(piece: str, square: str, board: Dict[str, str]) -> Set[str]:
    side = piece[0]
    x, y = sq_to_xy(square)
    d = -1 if side == "w" else 1
    out: Set[str] = set()
    one = (x, y + d)
    if on_board(*one):
        one_sq = xy_to_sq(*one)
        if one_sq not in board:
            out.add(one_sq)
            start_rank = 6 if side == "w" else 1
            two = (x, y + 2 * d)
            if y == start_rank and on_board(*two):
                two_sq = xy_to_sq(*two)
                if two_sq not in board:
                    out.add(two_sq)
    for dx in (-1, 1):
        nx, ny = x + dx, y + d
        if on_board(nx, ny):
            sq = xy_to_sq(nx, ny)
            if board.get(sq, "").startswith("b" if side == "w" else "w"):
                out.add(sq)
    return out


class ChessTrainer(tk.Tk):
    def __init__(self) -> None:
        super().__init__()
        self.title("Шахматный тренажёр")
        self.geometry("1200x820")
        self.resizable(True, True)
        self.minsize(980, 680)

        self.lang = "ru"
        self.is_fullscreen = False
        self.cell_size = 80
        self.board_offset_x = 0
        self.board_offset_y = 0
        self.board_flipped = False
        self.palette_height = 190
        self.palette_y = 0
        self.palette_rects: Dict[str, Tuple[int, int, int, int]] = {}
        self.drag_piece: Optional[str] = None
        self.drag_pos: Optional[Tuple[int, int]] = None

        self.mode_order = list(Mode)
        self.mode_index = 0
        self.mode = self.mode_order[self.mode_index]
        self.hints = True
        self.selected: Set[str] = set()
        self.feedback: Dict[str, str] = {}
        self.demo_marks: Dict[str, str] = {}
        self.task: Optional[Task] = None
        self.board: Dict[str, str] = dict(START_BOARD)
        self.free_selected_from: Optional[str] = None
        self.edit_selected_from: Optional[str] = None
        self.geometry_demo_phase = False
        self.mental_demo_phase = False
        self.task_counter = 0
        self.last_result_ok = False

        self._build_ui()
        self.bind("<space>", lambda _e: self.next_mode())
        self.bind("<h>", lambda _e: self.toggle_hints())
        self.bind("<H>", lambda _e: self.toggle_hints())
        self.bind("<r>", lambda _e: self.new_task())
        self.bind("<R>", lambda _e: self.new_task())
        self.bind("<d>", lambda _e: self.run_demo())
        self.bind("<D>", lambda _e: self.run_demo())
        self.bind("<l>", lambda _e: self.toggle_language())
        self.bind("<L>", lambda _e: self.toggle_language())
        self.bind("<F11>", lambda _e: self.toggle_fullscreen())
        self.bind("<Escape>", lambda _e: self.exit_fullscreen())

        self.new_task()

    def _build_ui(self) -> None:
        root = tk.Frame(self)
        root.pack(fill="both", expand=True, padx=12, pady=12)

        self.canvas = tk.Canvas(root, width=640, height=640, bg="white", highlightthickness=0)
        self.canvas.pack(side="left", fill="both", expand=True)
        self.canvas.bind("<ButtonPress-1>", self.on_left_press)
        self.canvas.bind("<B1-Motion>", self.on_left_motion)
        self.canvas.bind("<ButtonRelease-1>", self.on_left_release)
        self.canvas.bind("<Button-3>", self.on_right_click)
        self.canvas.bind("<Configure>", lambda _e: self.draw())

        side = tk.Frame(root, width=320)
        side.pack(side="right", fill="y")

        self.mode_label = tk.Label(side, text="", font=("Arial", 20, "bold"), wraplength=300, justify="left")
        self.mode_label.pack(anchor="w", pady=(0, 12))

        self.task_label = tk.Label(side, text="", font=("Arial", 15), wraplength=300, justify="left")
        self.task_label.pack(anchor="w", pady=(0, 12))

        self.hint_label = tk.Label(side, text="", font=("Arial", 12), wraplength=300, justify="left", fg="#666")
        self.hint_label.pack(anchor="w", pady=(0, 16))

        self.result_label = tk.Label(side, text="", font=("Arial", 13, "bold"), wraplength=300, justify="left")
        self.result_label.pack(anchor="w", pady=(0, 16))

        self.explain_label = tk.Label(side, text="", font=("Arial", 12), wraplength=300, justify="left", fg="#333")
        self.explain_label.pack(anchor="w", pady=(0, 14))

        self.controls_label = tk.Label(side, text="", justify="left", anchor="w", wraplength=300, font=("Arial", 12))
        self.controls_label.pack(anchor="w")

        self.lang_button = tk.Button(side, text="EN", command=self.toggle_language, font=("Arial", 12, "bold"))
        self.lang_button.pack(anchor="w", pady=(10, 0))

        self.rotate_button = tk.Button(side, text="↻ Поворот доски", command=self.toggle_board_rotation, font=("Arial", 12, "bold"))
        self.rotate_button.pack(anchor="w", pady=(10, 0))

        self.editor_piece_var = tk.StringVar(value="wP")
        editor_values = ["wP", "wN", "wB", "wR", "wQ", "wK", "bP", "bN", "bB", "bR", "bQ", "bK"]
        self.editor_menu = tk.OptionMenu(side, self.editor_piece_var, *editor_values)
        self.editor_menu.config(font=("Arial", 11))
        self.editor_menu.pack(anchor="w", pady=(10, 0))

    def _t(self, ru: str, en: str) -> str:
        return en if self.lang == "en" else ru

    def mode_title(self) -> str:
        titles = {
            Mode.COORDS: ("Координаты", "Coordinates"),
            Mode.COLOR: ("Цвет клетки", "Square color"),
            Mode.DIAGONALS: ("Диагонали", "Diagonals"),
            Mode.STARTING: ("Расстановка фигур", "Starting squares"),
            Mode.GEOMETRY: ("Геометрия фигур", "Piece geometry"),
            Mode.CONTROL: ("Контроль поля", "Square control"),
            Mode.MENTAL: ("Мысленные перемещения", "Mental moves"),
            Mode.PAWN: ("Обучение всем ходам", "All-piece move training"),
            Mode.FREE: ("Свободный режим", "Free mode"),
        }
        ru, en = titles[self.mode]
        return self._t(ru, en)

    def controls_text(self) -> str:
        return self._t(
            "Управление:\nSPACE — следующий режим\nH — подсказки вкл/выкл\nR — новая задача\nD — демонстрация правильного решения\nL — язык RU/EN\nF11 — полный экран\nESC — выход из полного экрана\nЛКМ — выбор/перемещение\nПКМ — убрать фигуру (в режимах редактирования)\nВнизу: перетащите фигуру с панели на доску",
            "Controls:\nSPACE — next mode\nH — hints on/off\nR — new task\nD — show demo solution\nL — language RU/EN\nF11 — fullscreen\nESC — exit fullscreen\nLMB — select/move\nRMB — remove piece (in edit modes)\nBottom panel: drag a piece onto board",
        )

    def toggle_language(self) -> None:
        self.lang = "en" if self.lang == "ru" else "ru"
        self.localize_current_task_text()
        self.update_labels()
        self.draw()

    def localize_current_task_text(self) -> None:
        if not self.task:
            return

        task = self.task

        if self.mode == Mode.COORDS:
            sq = next(iter(task.expected), "")
            task.prompt = self._t(f"Найдите клетку: {sq}", f"Find square: {sq}")
            task.hint = self._t(f"Координата: {sq}", f"Coordinate: {sq}")
            task.explain = self._t(
                f"Объяснение простыми словами: буква {sq[0]} — столбец, цифра {sq[1]} — ряд. Нужна ровно клетка {sq}.",
                f"Simple: file is {sq[0]}, rank is {sq[1]}. You need exactly square {sq}.",
            )
            return

        if self.mode == Mode.DIAGONALS and task.board:
            s, piece = next(iter(task.board.items()))
            if piece == "wR":
                task.prompt = self._t(
                    f"Прямые линии ладьи: отметьте все клетки хода с {s}",
                    f"Rook lines: mark all legal rook line squares from {s}",
                )
                task.hint = self._t("Для ладьи — только вертикаль и горизонталь", "For rook: only file and rank lines")
                task.explain = self._t(
                    "Этот подрежим специально тренирует прямые линии ладьи.",
                    "This submode specifically trains rook straight lines.",
                )
            else:
                who_ru = "слона" if piece == "wB" else "ферзя"
                who_en = "bishop" if piece == "wB" else "queen"
                task.prompt = self._t(
                    f"Диагонали: отметьте все диагональные клетки для {who_ru} из {s}",
                    f"Diagonals: mark all diagonal squares for {who_en} from {s}",
                )
                task.hint = self._t("Только диагонали, без вертикалей/горизонталей", "Only diagonals, no vertical/horizontal")
                task.explain = self._t(
                    "Режим диагоналей тренирует именно диагональные лучи, отдельно от общей геометрии фигур.",
                    "Diagonals mode trains diagonal rays separately from general piece geometry.",
                )
            return

        if self.mode == Mode.STARTING:
            if task.required_board:
                side = "w" if any(v.startswith("w") for v in task.required_board.values()) else "b"
                squares_text = ", ".join(sorted(task.required_board.keys()))
                task.prompt = self._t(
                    f"Свободная расстановка: расставьте ВСЕ фигуры стороны {'белые' if side == 'w' else 'чёрные'} на стартовые клетки.",
                    f"Free setup: place ALL {('white' if side == 'w' else 'black')} pieces on starting squares.",
                )
                task.hint = self._t(f"Нужно точно заполнить клетки: {squares_text}", f"Fill exact squares: {squares_text}")
                task.explain = self._t(
                    "Это свободный режим внутри расстановки: перетаскивайте фигуры мышью с панели снизу. Ошибки покажутся красным, верные клетки — зелёным.",
                    "This is a free setup drill: drag pieces from the bottom palette. Errors are red, correct squares are green.",
                )
            elif task.target_piece:
                piece = task.target_piece
                side_ru = "белую" if piece[0] == "w" else "чёрную"
                side_en = "white" if piece[0] == "w" else "black"
                names_ru = {"K": "короля", "Q": "ферзя", "R": "ладью", "B": "слона", "N": "коня", "P": "пешку"}
                names_en = {"K": "king", "Q": "queen", "R": "rook", "B": "bishop", "N": "knight", "P": "pawn"}
                cells = ", ".join(sorted(task.expected))
                if len(task.expected) == 1:
                    task.prompt = self._t(
                        f"Поставьте {side_ru} {names_ru[piece[1]]} на клетку {cells}.",
                        f"Place {side_en} {names_en[piece[1]]} on square {cells}.",
                    )
                else:
                    task.prompt = self._t(
                        f"Поставьте ВСЕ {len(task.expected)} {side_ru} {names_ru[piece[1]]} на клетки: {cells}.",
                        f"Place ALL {len(task.expected)} {side_en} {names_en[piece[1]]} pieces on squares: {cells}.",
                    )
                task.hint = self._t(f"Точные клетки: {cells}", f"Exact squares: {cells}")
                task.explain = self._t(
                    "Перетаскивайте фигуры с нижней панели. Нужные клетки перечислены в задании, поэтому не надо угадывать 'какая стартовая'.",
                    "Drag pieces from the bottom palette. Required squares are listed explicitly.",
                )
            return

        if self.mode == Mode.GEOMETRY and task.board:
            sq, piece = next(iter(task.board.items()))
            names_ru = {"N": "коня", "B": "слона", "R": "ладьи", "Q": "ферзя", "K": "короля"}
            task.prompt = self._t(
                f"Геометрия фигур: отметьте все ходы {names_ru[piece[1]]} с {sq} (чистая геометрия, без других фигур).",
                f"Piece geometry: mark all legal moves from {sq} on an empty board.",
            )
            task.hint = self._t(
                "Здесь изучается базовая геометрия хода фигуры на пустой доске",
                "This mode studies pure move geometry on an empty board",
            )
            task.explain = self._t(
                "Отдельный режим от 'Диагоналей' и 'Обучения всем ходам': только базовая форма хода фигуры.",
                "Separate from diagonals/all-moves: only the base move shape of each piece.",
            )
            return

        if self.mode == Mode.CONTROL and task.control_side:
            task.prompt = self._t(
                f"Кликните все клетки под боем стороны {'белыми' if task.control_side == 'w' else 'чёрными'}",
                f"Mark all squares controlled by {('white' if task.control_side == 'w' else 'black')} side",
            )
            task.hint = self._t("Нужно отметить каждую атакуемую клетку", "Mark every attacked square")
            task.explain = self._t(
                "Цель: по текущей позиции отметить ВСЕ клетки, которые сторона может атаковать следующим ходом. Позиция фиксированная: просто анализируйте и отмечайте поля под боем.",
                "Goal: in this fixed position mark ALL squares this side can attack on the next move.",
            )
            return

        if self.mode == Mode.MENTAL and task.board:
            sq, piece = next(iter(task.board.items()))
            names_ru = {"N": "конь", "B": "слон", "R": "ладья", "Q": "ферзь"}
            task.prompt = self._t(
                f"Мысленные перемещения: та же фигура '{names_ru[piece[1]]}'. После демонстрации (D) решите задание на новом поле со старта {sq}.",
                f"Mental moves: same piece. After demo (D), solve on a new square from {sq}.",
            )
            task.hint = self._t(f"Задание идёт по скрытой траектории от {sq}", f"Task follows a hidden route from {sq}")
            task.explain = self._t(
                "Сначала демонстрация пути на одном поле, затем аналогичное задание без демонстрации на другом поле.",
                "First: route demo on one square. Then: same-piece task on another square without demo.",
            )
            return

        if self.mode == Mode.PAWN and task.board:
            sq, piece = next(iter(task.board.items()))
            task.prompt = self._t(
                f"Обучение всем ходам: выберите все легальные ходы для фигуры {piece} с {sq}",
                f"All-piece training: mark all legal moves for {piece} from {sq}",
            )
            task.hint = self._t(
                "Учитывайте и обычные ходы, и взятия, и блокировки фигурами",
                "Consider normal moves, captures, and blockers",
            )
            task.explain = self._t(
                "Этот режим тренирует ходы ВСЕХ фигур. Нажмите D для демонстрации корректных клеток, затем повторите самостоятельно.",
                "This mode trains moves of ALL pieces. Press D for demo squares, then repeat on your own.",
            )

    def toggle_fullscreen(self) -> None:
        self.is_fullscreen = not self.is_fullscreen
        self.attributes("-fullscreen", self.is_fullscreen)

    def toggle_board_rotation(self) -> None:
        self.board_flipped = not self.board_flipped
        self.draw()

    def exit_fullscreen(self) -> None:
        if self.is_fullscreen:
            self.is_fullscreen = False
            self.attributes("-fullscreen", False)

    def _board_metrics(self) -> Tuple[int, int, int]:
        w = max(self.canvas.winfo_width(), 1)
        h = max(self.canvas.winfo_height(), 1)
        board_space_h = max(h - self.palette_height, 8)
        board_px = min(w, board_space_h)
        self.cell_size = max(board_px // 8, 1)
        board_px = self.cell_size * 8
        self.board_offset_x = (w - board_px) // 2
        self.board_offset_y = max((board_space_h - board_px) // 2, 0)
        self.palette_y = self.board_offset_y + board_px + 10
        return self.cell_size, self.board_offset_x, self.board_offset_y

    def _display_to_board_coords(self, x: int, y: int) -> Tuple[int, int]:
        if self.board_flipped:
            return 7 - x, 7 - y
        return x, y

    def _board_to_display_coords(self, x: int, y: int) -> Tuple[int, int]:
        if self.board_flipped:
            return 7 - x, 7 - y
        return x, y

    def _is_edit_mode(self) -> bool:
        return self.mode in {Mode.STARTING}

    def _recompute_control_expected(self) -> None:
        if not self.task or self.mode != Mode.CONTROL or not self.task.control_side:
            return
        exp: Set[str] = set()
        for sq, p in self.board.items():
            if p[0] == self.task.control_side:
                exp |= piece_attacks(p, sq, self.board)
        self.task.expected = exp

    def _palette_piece_at(self, x: int, y: int) -> Optional[str]:
        for piece, (x0, y0, x1, y1) in self.palette_rects.items():
            if x0 <= x <= x1 and y0 <= y <= y1:
                return piece
        return None

    def on_left_press(self, event: tk.Event) -> None:
        if self._is_edit_mode():
            palette_piece = self._palette_piece_at(event.x, event.y)
            if palette_piece:
                self.drag_piece = palette_piece
                self.drag_pos = (event.x, event.y)
                self.draw()
                return
        self.on_click(event)

    def on_left_motion(self, event: tk.Event) -> None:
        if self.drag_piece:
            self.drag_pos = (event.x, event.y)
            self.draw()

    def on_left_release(self, event: tk.Event) -> None:
        if not self.drag_piece:
            return
        piece = self.drag_piece
        self.drag_piece = None
        self.drag_pos = None

        cell, off_x, off_y = self._board_metrics()
        dx = (event.x - off_x) // cell
        dy = (event.y - off_y) // cell
        if on_board(dx, dy):
            x, y = self._display_to_board_coords(dx, dy)
            sq = xy_to_sq(x, y)
            self.board[sq] = piece
            if self.mode == Mode.CONTROL:
                self._recompute_control_expected()
            if self.mode == Mode.STARTING:
                self.evaluate_starting_task()
        self.draw()

    def next_mode(self) -> None:
        self.mode_index = (self.mode_index + 1) % len(self.mode_order)
        self.mode = self.mode_order[self.mode_index]
        self.new_task()

    def toggle_hints(self) -> None:
        self.hints = not self.hints
        self.update_labels()
        self.draw()

    def new_task(self) -> None:
        self.selected = set()
        self.feedback = {}
        self.demo_marks = {}
        self.free_selected_from = None
        self.edit_selected_from = None
        self.geometry_demo_phase = False
        self.mental_demo_phase = False
        self.last_result_ok = False
        self.task_counter += 1
        if self.mode == Mode.FREE:
            self.task = Task(
                number=self.task_counter,
                prompt=self._t(
                    "Свободная игра: выберите фигуру и затем клетку назначения.",
                    "Free play: select a piece and then a destination square.",
                ),
                expected=set(),
                multi=False,
                board=dict(START_BOARD),
                explain=self._t(
                    "Свободный режим: сначала клик по фигуре, потом по клетке назначения.",
                    "Free mode: first click a piece, then click destination square.",
                )
            )
            self.board = dict(START_BOARD)
        else:
            self.task = self.generate_task(self.mode)
            self.board = dict(self.task.board or {})
            if self.mode == Mode.STARTING and self.task.target_piece:
                self.editor_piece_var.set(self.task.target_piece)
            if self.mode == Mode.GEOMETRY:
                self.geometry_demo_phase = True
            if self.mode == Mode.MENTAL:
                self.mental_demo_phase = True
        self.update_labels()
        self.draw()

    def generate_task(self, mode: Mode) -> Task:
        if mode == Mode.COORDS:
            sq = random.choice(SQUARES)
            return Task(
                number=self.task_counter,
                prompt=self._t(f"Найдите клетку: {sq}", f"Find square: {sq}"),
                expected={sq},
                multi=False,
                board={},
                hint=self._t(f"Координата: {sq}", f"Coordinate: {sq}"),
                explain=self._t(
                    f"Объяснение простыми словами: буква {sq[0]} — столбец, цифра {sq[1]} — ряд. Нужна ровно клетка {sq}.",
                    f"Simple: file is {sq[0]}, rank is {sq[1]}. You need exactly square {sq}.",
                )
            )

        if mode == Mode.COLOR:
            c = random.choice(["light", "dark"])
            txt = "светлую" if c == "light" else "тёмную"
            pool = {sq for sq in SQUARES if square_color(sq) == c}
            variant = random.choice(["any", "file", "rank"])

            if variant == "file":
                file = random.choice(FILES)
                expected = {sq for sq in pool if sq[0] == file}
                prompt = self._t(f"Кликните {txt} клетку на вертикали {file}", f"Click a {c} square on file {file}")
                hint = self._t(f"Нужна {txt} клетка на линии {file}", f"Need a {c} square on file {file}")
            elif variant == "rank":
                rank = random.choice(RANKS)
                expected = {sq for sq in pool if sq[1] == rank}
                prompt = self._t(f"Кликните {txt} клетку на горизонтали {rank}", f"Click a {c} square on rank {rank}")
                hint = self._t(f"Нужна {txt} клетка на линии {rank}", f"Need a {c} square on rank {rank}")
            else:
                expected = pool
                prompt = self._t(f"Кликните любую {txt} клетку", f"Click any {c} square")
                hint = self._t(f"Нужно выбрать {txt} клетку", f"Choose a {c} square")

            return Task(
                number=self.task_counter,
                prompt=prompt,
                expected=expected,
                multi=False,
                board={},
                hint=hint,
                explain=self._t(
                    "Смотри на условие и цвет клетки. Если задана линия (буква/цифра), клетка должна одновременно подходить и по цвету, и по линии.",
                    "Read condition and color. If file/rank is specified, the square must match both color and line.",
                ),
            )

        if mode == Mode.DIAGONALS:
            s = random.choice(SQUARES)
            piece = random.choice(["wB", "wQ", "wR"])
            if piece == "wR":
                exp = piece_attacks(piece, s, {})
                prompt = f"Прямые линии ладьи: отметьте все клетки хода с {s}"
                hint = "Для ладьи — только вертикаль и горизонталь"
                explain = "Этот подрежим специально тренирует прямые линии ладьи."
            else:
                x, y = sq_to_xy(s)
                exp = set()
                for dx, dy in [(-1, -1), (-1, 1), (1, -1), (1, 1)]:
                    nx, ny = x + dx, y + dy
                    while on_board(nx, ny):
                        exp.add(xy_to_sq(nx, ny))
                        nx += dx
                        ny += dy
                who = "слона" if piece == "wB" else "ферзя"
                prompt = f"Диагонали: отметьте все диагональные клетки для {who} из {s}"
                hint = "Только диагонали, без вертикалей/горизонталей"
                explain = "Режим диагоналей тренирует именно диагональные лучи, отдельно от общей геометрии фигур."

            if self.lang == "en":
                if piece == "wR":
                    prompt = f"Rook lines: mark all legal rook line squares from {s}"
                    hint = "For rook: only file and rank lines"
                    explain = "This submode specifically trains rook straight lines."
                else:
                    who_en = "bishop" if piece == "wB" else "queen"
                    prompt = f"Diagonals: mark all diagonal squares for {who_en} from {s}"
                    hint = "Only diagonals, no vertical/horizontal"
                    explain = "Diagonals mode trains diagonal rays separately from general piece geometry."
            return Task(
                number=self.task_counter,
                prompt=prompt,
                expected=exp,
                multi=True,
                board={s: piece},
                hint=hint,
                explain=explain,
            )

        if mode == Mode.STARTING:
            starts = {
                "wK": ["e1"], "wQ": ["d1"], "wR": ["a1", "h1"], "wB": ["c1", "f1"], "wN": ["b1", "g1"], "wP": [f"{f}2" for f in FILES],
                "bK": ["e8"], "bQ": ["d8"], "bR": ["a8", "h8"], "bB": ["c8", "f8"], "bN": ["b8", "g8"], "bP": [f"{f}7" for f in FILES],
            }
            variant = random.choice(["piece", "free_setup"])
            if variant == "free_setup":
                side = random.choice(["w", "b"])
                side_name = "белые" if side == "w" else "чёрные"
                required_board = {sq: p for sq, p in START_BOARD.items() if p[0] == side}
                squares_text = ", ".join(sorted(required_board.keys()))
                return Task(
                    number=self.task_counter,
                    prompt=self._t(
                        f"Свободная расстановка: расставьте ВСЕ фигуры стороны {side_name} на стартовые клетки.",
                        f"Free setup: place ALL {('white' if side == 'w' else 'black')} pieces on starting squares.",
                    ),
                    expected=set(required_board.keys()),
                    multi=False,
                    board={},
                    hint=self._t(f"Нужно точно заполнить клетки: {squares_text}", f"Fill exact squares: {squares_text}"),
                    explain=self._t(
                        "Это свободный режим внутри расстановки: перетаскивайте фигуры мышью с панели снизу. Ошибки покажутся красным, верные клетки — зелёным.",
                        "This is a free setup drill: drag pieces from the bottom palette. Errors are red, correct squares are green.",
                    ),
                    required_board=required_board,
                )

            piece = random.choice(list(starts))
            exp = set(starts[piece])
            side = "белую" if piece[0] == "w" else "чёрную"
            side_en = "white" if piece[0] == "w" else "black"
            names = {"K": "короля", "Q": "ферзя", "R": "ладью", "B": "слона", "N": "коня", "P": "пешку"}
            names_en = {"K": "king", "Q": "queen", "R": "rook", "B": "bishop", "N": "knight", "P": "pawn"}
            cells = ", ".join(sorted(exp))
            if len(exp) == 1:
                prompt = f"Поставьте {side} {names[piece[1]]} на клетку {cells}."
                prompt_en = f"Place {side_en} {names_en[piece[1]]} on square {cells}."
            else:
                prompt = f"Поставьте ВСЕ {len(exp)} {side} {names[piece[1]]} на клетки: {cells}."
                prompt_en = f"Place ALL {len(exp)} {side_en} {names_en[piece[1]]} pieces on squares: {cells}."
            return Task(
                number=self.task_counter,
                prompt=self._t(prompt, prompt_en),
                expected=exp,
                multi=False,
                board={},
                hint=self._t(f"Точные клетки: {cells}", f"Exact squares: {cells}"),
                explain=self._t(
                    "Перетаскивайте фигуры с нижней панели. Нужные клетки перечислены в задании, поэтому не надо угадывать 'какая стартовая'.",
                    "Drag pieces from the bottom palette. Required squares are listed explicitly.",
                ),
                target_piece=piece,
            )

        if mode == Mode.GEOMETRY:
            sq = random.choice(SQUARES)
            piece = random.choice(["wN", "wB", "wR", "wQ", "wK"])
            exp = piece_attacks(piece, sq, {})
            names = {"N": "конь", "B": "слон", "R": "ладья", "Q": "ферзь", "K": "король"}
            return Task(
                number=self.task_counter,
                prompt=self._t(
                    f"Геометрия фигур: отметьте все ходы {names[piece[1]]} с {sq} (чистая геометрия, без других фигур).",
                    f"Piece geometry: mark all legal moves from {sq} on an empty board.",
                ),
                expected=exp,
                multi=True,
                board={sq: piece},
                hint=self._t("Здесь изучается базовая геометрия хода фигуры на пустой доске", "This mode studies pure move geometry on an empty board"),
                explain=self._t(
                    "Отдельный режим от 'Диагоналей' и 'Обучения всем ходам': только базовая форма хода фигуры.",
                    "Separate from diagonals/all-moves: only the base move shape of each piece.",
                ),
            )

        if mode == Mode.CONTROL:
            board = {}
            pieces = ["wQ", "wR", "wB", "wN", "wK", "wP", "wP", "wP"]
            random.shuffle(pieces)
            placed = random.sample(SQUARES, 6)
            for i, sq in enumerate(placed):
                p = pieces[i]
                board[sq] = p if random.random() > 0.35 else p.replace("w", "b")
            side = random.choice(["w", "b"])
            exp: Set[str] = set()
            for sq, p in board.items():
                if p[0] == side:
                    if p[1] == "P":
                        exp |= piece_attacks(p, sq, board)
                    else:
                        exp |= piece_attacks(p, sq, board)
            team = "белыми" if side == "w" else "чёрными"
            return Task(
                number=self.task_counter,
                prompt=self._t(f"Кликните все клетки под боем стороны {team}", f"Mark all squares controlled by {('white' if side == 'w' else 'black')} side"),
                expected=exp,
                multi=True,
                board=board,
                hint=self._t("Нужно отметить каждую атакуемую клетку", "Mark every attacked square"),
                explain=self._t(
                    "Цель: по текущей позиции отметить ВСЕ клетки, которые сторона может атаковать следующим ходом. Позиция фиксированная: просто анализируйте и отмечайте поля под боем.",
                    "Goal: in this fixed position mark ALL squares this side can attack on the next move.",
                ),
                control_side=side,
            )

        if mode == Mode.MENTAL:
            piece = random.choice(["wN", "wB", "wR", "wQ"])
            demo_start = random.choice(SQUARES)
            demo_path = [demo_start]
            cur = demo_start
            for _ in range(random.randint(2, 4)):
                moves = list(piece_attacks(piece, cur, {}))
                if not moves:
                    break
                cur = random.choice(moves)
                demo_path.append(cur)

            challenge_start = random.choice([sq for sq in SQUARES if sq != demo_start])
            challenge_path = [challenge_start]
            cur = challenge_start
            for _ in range(random.randint(2, 4)):
                moves = list(piece_attacks(piece, cur, {}))
                if not moves:
                    break
                cur = random.choice(moves)
                challenge_path.append(cur)

            exp = {challenge_path[-1]}
            names = {"N": "конь", "B": "слон", "R": "ладья", "Q": "ферзь"}
            return Task(
                number=self.task_counter,
                prompt=self._t(
                    f"Мысленные перемещения: та же фигура '{names[piece[1]]}'. После демонстрации (D) решите задание на новом поле со старта {challenge_start}.",
                    f"Mental moves: same piece. After demo (D), solve on a new square from {challenge_start}.",
                ),
                expected=exp,
                multi=False,
                board={challenge_start: piece},
                hint=self._t(f"Задание идёт по скрытой траектории от {challenge_start}", f"Task follows a hidden route from {challenge_start}"),
                explain=self._t(
                    "Сначала демонстрация пути на одном поле, затем аналогичное задание без демонстрации на другом поле.",
                    "First: route demo on one square. Then: same-piece task on another square without demo.",
                ),
                demo_path=demo_path,
                challenge_path=challenge_path,
            )

        # ALL-PIECE MOVE TRAINING
        side = random.choice(["w", "b"])
        kind = random.choice(["P", "N", "B", "R", "Q", "K"])
        sq = random.choice(SQUARES)
        board = {sq: f"{side}{kind}"}
        for block_sq in random.sample([x for x in SQUARES if x != sq], 6):
            if random.random() > 0.55:
                block_side = side if random.random() > 0.5 else ("b" if side == "w" else "w")
                block_kind = random.choice(["P", "N", "B", "R", "Q"])
                board[block_sq] = f"{block_side}{block_kind}"

        piece = f"{side}{kind}"
        exp = pawn_moves(piece, sq, board) if kind == "P" else piece_attacks(piece, sq, board)
        side_text = "белой" if side == "w" else "чёрной"
        names = {"P": "пешки", "N": "коня", "B": "слона", "R": "ладьи", "Q": "ферзя", "K": "короля"}
        return Task(
            number=self.task_counter,
            prompt=self._t(
                f"Обучение всем ходам: выберите все легальные ходы для {side_text} {names[kind]} с {sq}",
                f"All-piece training: mark all legal moves for {piece} from {sq}",
            ),
            expected=exp,
            multi=True,
            board=board,
            hint=self._t("Учитывайте и обычные ходы, и взятия, и блокировки фигурами", "Consider normal moves, captures, and blockers"),
            explain=self._t(
                "Этот режим тренирует ходы ВСЕХ фигур. Нажмите D для демонстрации корректных клеток, затем повторите самостоятельно.",
                "This mode trains moves of ALL pieces. Press D for demo squares, then repeat on your own.",
            ),
        )

    def update_labels(self) -> None:
        assert self.task is not None
        year = date.today().year
        mode_text = self._t("Режим", "Mode")
        task_text = self._t("Задача", "Task")
        of_text = self._t("из", "of")
        self.mode_label.config(
            text=f"{mode_text}: {self.mode_title()}\n{task_text} {self.task.number} {of_text} {MAX_REAL_TASKS:,} ({year})"
        )
        self.task_label.config(text=self.task.prompt)
        hint = self.task.hint if self.hints else self._t("Подсказки выключены", "Hints are off")
        self.hint_label.config(text=f"{self._t('Подсказка', 'Hint')}: {hint}")
        self.explain_label.config(text=f"{self._t('Пояснение для новичка', 'Beginner explanation')}:\n{self.task.explain}")
        self.controls_label.config(text=self.controls_text())
        self.lang_button.config(text=("EN" if self.lang == "ru" else "RU"))
        self.rotate_button.config(text=self._t("↻ Поворот доски", "↻ Rotate board"))
        if self._is_edit_mode():
            self.editor_menu.config(state="normal")
        else:
            self.editor_menu.config(state="disabled")

        if self.mode == Mode.GEOMETRY and self.geometry_demo_phase:
            self.result_label.config(
                text=self._t(
                    "Показ ходов активен: запомните подсветку и кликните по доске для повтора.",
                    "Move demo is active: memorize highlighted squares and click board to repeat.",
                ),
                fg=DEMO,
            )
        if self.mode == Mode.MENTAL and self.mental_demo_phase:
            self.result_label.config(
                text=self._t(
                    "Шаг 1: нажмите D и посмотрите демонстрацию маршрута. Шаг 2: решите новую траекторию без показа.",
                    "Step 1: press D to view route demo. Step 2: solve a new route without demo.",
                ),
                fg=DEMO,
            )

    def on_click(self, event: tk.Event) -> None:
        cell, off_x, off_y = self._board_metrics()
        dx = (event.x - off_x) // cell
        dy = (event.y - off_y) // cell
        if not on_board(dx, dy):
            return
        x, y = self._display_to_board_coords(dx, dy)
        sq = xy_to_sq(x, y)

        if self.mode == Mode.FREE:
            self.handle_free_move(sq)
            self.draw()
            return

        if self._is_edit_mode():
            self.handle_edit_click(sq)
            self.draw()
            return

        if self.mode == Mode.GEOMETRY and self.geometry_demo_phase:
            self.geometry_demo_phase = False
            self.selected = set()
            self.feedback = {}
            self.result_label.config(
                text=self._t("Теперь повторите без подсказок.", "Now repeat without hints."),
                fg="#333",
            )
            self.draw()
            return

        if self.mode == Mode.MENTAL and self.mental_demo_phase:
            self.mental_demo_phase = False
            self.result_label.config(
                text=self._t(
                    "Теперь решите задачу на другом поле без демонстрации.",
                    "Now solve the task on another square without demonstration.",
                ),
                fg="#333",
            )

        assert self.task is not None
        if self.task.multi:
            if sq in self.selected:
                self.selected.remove(sq)
            else:
                self.selected.add(sq)
            if len(self.selected) >= len(self.task.expected):
                self.evaluate()
        else:
            self.selected = {sq}
            self.evaluate()
        self.draw()

    def on_right_click(self, event: tk.Event) -> None:
        if not self._is_edit_mode():
            return
        cell, off_x, off_y = self._board_metrics()
        dx = (event.x - off_x) // cell
        dy = (event.y - off_y) // cell
        if not on_board(dx, dy):
            return
        x, y = self._display_to_board_coords(dx, dy)
        sq = xy_to_sq(x, y)
        if sq in self.board:
            self.board.pop(sq, None)
            self.edit_selected_from = None
            if self.mode == Mode.CONTROL:
                self._recompute_control_expected()
            if self.mode == Mode.STARTING:
                self.evaluate_starting_task()
            self.draw()

    def handle_edit_click(self, sq: str) -> None:
        self.selected = set()
        self.feedback = {}
        piece = self.board.get(sq)
        if self.edit_selected_from:
            moving = self.board.get(self.edit_selected_from)
            if moving:
                self.board[sq] = moving
                self.board.pop(self.edit_selected_from, None)
            self.edit_selected_from = None
        elif piece:
            self.edit_selected_from = sq
            self.editor_piece_var.set(piece)
        else:
            self.board[sq] = self.editor_piece_var.get()

        if self.mode == Mode.CONTROL:
            self._recompute_control_expected()
        if self.mode == Mode.STARTING:
            self.evaluate_starting_task()

    def handle_free_move(self, sq: str) -> None:
        piece = self.board.get(sq)
        if self.free_selected_from is None:
            if piece:
                self.free_selected_from = sq
                self.result_label.config(
                    text=self._t(
                        f"Выбрана фигура {PIECE_SYMBOLS[piece]} на {sq}",
                        f"Selected {PIECE_SYMBOLS[piece]} on {sq}",
                    ),
                    fg="#222",
                )
            else:
                self.result_label.config(
                    text=self._t("Пустая клетка. Сначала выберите фигуру.", "Empty square. Select a piece first."),
                    fg=BAD,
                )
            return

        src = self.free_selected_from
        moving = self.board.get(src)
        self.free_selected_from = None
        if not moving:
            self.result_label.config(
                text=self._t("Фигура исчезла, выберите снова.", "Piece is no longer there, select again."),
                fg=BAD,
            )
            return
        legal = pawn_moves(moving, src, self.board) if moving[1] == "P" else piece_attacks(moving, src, self.board)
        if sq in legal:
            self.board[sq] = moving
            self.board.pop(src, None)
            self.result_label.config(text=self._t(f"✅ Верный ход: {src} → {sq}", f"✅ Correct move: {src} → {sq}"), fg=GOOD)
        else:
            self.result_label.config(text=self._t(f"❌ Неверный ход: {src} → {sq}", f"❌ Wrong move: {src} → {sq}"), fg=BAD)

    def evaluate(self) -> None:
        assert self.task is not None
        exp, got = self.task.expected, self.selected
        self.feedback = {}

        if not self.task.multi:
            chosen = next(iter(got), None)
            ok = chosen is not None and chosen in exp
            if chosen is not None:
                self.feedback[chosen] = GOOD if ok else BAD
            if not ok and exp:
                self.feedback[next(iter(exp))] = HINT
        else:
            ok = True
            for sq in got - exp:
                self.feedback[sq] = BAD
                ok = False
            for sq in exp & got:
                self.feedback[sq] = GOOD
            for sq in exp - got:
                self.feedback[sq] = HINT
                ok = False
            ok = ok and len(got) == len(exp)

        if ok:
            self.last_result_ok = True
            self.result_label.config(text=self._t("✅ Правильно! Нажмите R для новой задачи.", "✅ Correct! Press R for a new task."), fg=GOOD)
            self.after(1200, self._auto_next_after_success)
        else:
            self.last_result_ok = False
            self.result_label.config(
                text=self._t("❌ Есть ошибки. Красные — неверно, жёлтые — пропущено.", "❌ Mistakes found. Red = wrong, yellow = missed."),
                fg=BAD,
            )

    def evaluate_starting_task(self) -> None:
        if not self.task:
            return
        self.feedback = {}
        ok = True

        if self.task.required_board:
            required = self.task.required_board
            for sq, need_piece in required.items():
                current = self.board.get(sq)
                if current == need_piece:
                    self.feedback[sq] = GOOD
                else:
                    ok = False
                    self.feedback[sq] = BAD if current else HINT
            for sq, piece in self.board.items():
                if sq not in required and piece in required.values():
                    self.feedback[sq] = BAD
                    ok = False
        else:
            target = self.task.target_piece
            expected = self.task.expected
            for sq in expected:
                if self.board.get(sq) == target:
                    self.feedback[sq] = GOOD
                else:
                    self.feedback[sq] = HINT
                    ok = False
            for sq, piece in self.board.items():
                if piece == target and sq not in expected:
                    self.feedback[sq] = BAD
                    ok = False

        if ok:
            self.last_result_ok = True
            self.result_label.config(
                text=self._t("✅ Верно расставлено. Следующая задача откроется автоматически.", "✅ Correct setup. Next task will open automatically."),
                fg=GOOD,
            )
            self.after(1200, self._auto_next_after_success)
        else:
            self.last_result_ok = False
            self.result_label.config(
                text=self._t("❌ Пока не верно. Красные клетки — ошибка, жёлтые — недостающие.", "❌ Not yet. Red = wrong placement, yellow = missing piece."),
                fg=BAD,
            )

    def _auto_next_after_success(self) -> None:
        if self.last_result_ok and self.mode != Mode.FREE:
            self.new_task()

    def run_demo(self) -> None:
        if self.mode == Mode.FREE or not self.task:
            self.result_label.config(
                text=self._t(
                    "Демо в свободном режиме не нужно: здесь вы играете сами.",
                    "Demo is not needed in free mode: you play by yourself.",
                ),
                fg="#333",
            )
            return
        if self.mode == Mode.MENTAL:
            if self.mental_demo_phase and self.task.demo_path:
                path = self.task.demo_path
                self.demo_marks = {sq: DEMO for sq in path}
                self.result_label.config(
                    text=self._t(
                        f"Демонстрация траектории: {' → '.join(path)}",
                        f"Trajectory demo: {' → '.join(path)}",
                    ),
                    fg=DEMO,
                )
            else:
                self.result_label.config(
                    text=self._t(
                        "Для второго этапа демонстрация отключена: решите путь самостоятельно.",
                        "Demo is disabled on stage 2: solve route by yourself.",
                    ),
                    fg=BAD,
                )
            self.draw()
            return
        self.demo_marks = {sq: DEMO for sq in self.task.expected}
        ordered = sorted(self.task.expected, key=lambda s: (s[1], s[0]))
        if ordered:
            step_text = " → ".join(ordered)
            self.result_label.config(text=self._t(f"Демонстрация решения: {step_text}", f"Solution demo: {step_text}"), fg=DEMO)
        self.draw()

    def draw(self) -> None:
        self.canvas.delete("all")
        cell, off_x, off_y = self._board_metrics()
        self.palette_rects = {}
        for y in range(8):
            for x in range(8):
                sq = xy_to_sq(x, y)
                color = LIGHT if square_color(sq) == "light" else DARK
                dx, dy = self._board_to_display_coords(x, y)
                x0 = off_x + dx * cell
                y0 = off_y + dy * cell
                x1 = x0 + cell
                y1 = y0 + cell
                self.canvas.create_rectangle(x0, y0, x1, y1, fill=color, outline=color)

                show_hint = self.hints and self.task and sq in self.task.expected and self.mode != Mode.FREE
                if self.mode == Mode.GEOMETRY and not self.geometry_demo_phase:
                    show_hint = False
                if self.mode == Mode.GEOMETRY and self.geometry_demo_phase and self.task and sq in self.task.expected:
                    show_hint = True
                if show_hint:
                    self.canvas.create_rectangle(
                        x0 + cell * 0.25,
                        y0 + cell * 0.25,
                        x0 + cell * 0.75,
                        y0 + cell * 0.75,
                        outline=HINT,
                        width=2,
                    )

                if sq in self.selected:
                    self.canvas.create_rectangle(x0 + 2, y0 + 2, x1 - 2, y1 - 2, outline=SEL, width=3)
                if self.edit_selected_from == sq:
                    self.canvas.create_rectangle(x0 + 2, y0 + 2, x1 - 2, y1 - 2, outline=DEMO, width=3)
                if sq in self.feedback:
                    self.canvas.create_rectangle(x0 + 6, y0 + 6, x1 - 6, y1 - 6, outline=self.feedback[sq], width=4)
                if sq in self.demo_marks:
                    self.canvas.create_rectangle(x0 + 12, y0 + 12, x1 - 12, y1 - 12, outline=self.demo_marks[sq], width=3)

                piece = self.board.get(sq)
                if piece:
                    self.canvas.create_text(
                        x0 + cell / 2,
                        y0 + cell / 2,
                        text=PIECE_SYMBOLS[piece],
                        font=("Arial", max(int(cell * 0.55), 16)),
                    )

                if y == 7:
                    self.canvas.create_text(
                        x0 + cell - 10,
                        y1 - 10,
                        text=sq[0],
                        font=("Arial", max(int(cell * 0.12), 9)),
                        fill="#333",
                    )
                if x == 0:
                    self.canvas.create_text(
                        x0 + 10,
                        y0 + 10,
                        text=sq[1],
                        font=("Arial", max(int(cell * 0.12), 9)),
                        fill="#333",
                    )

        top_side = self._t("Сторона чёрных", "Black side") if not self.board_flipped else self._t("Сторона белых", "White side")
        bottom_side = self._t("Сторона белых", "White side") if not self.board_flipped else self._t("Сторона чёрных", "Black side")
        board_px = cell * 8
        self.canvas.create_text(
            off_x + board_px / 2,
            max(off_y - 14, 8),
            text=top_side,
            font=("Arial", 10, "bold"),
            fill="#444",
        )
        self.canvas.create_text(
            off_x + board_px / 2,
            off_y + board_px + 14,
            text=bottom_side,
            font=("Arial", 10, "bold"),
            fill="#444",
        )

        palette_pieces = ["wP", "wN", "wB", "wR", "wQ", "wK", "bP", "bN", "bB", "bR", "bQ", "bK"]
        pw = max(int(cell * 0.75), 34)
        ph = max(int(cell * 0.75), 34)
        gap_x = 8
        gap_y = 16
        canvas_w = max(self.canvas.winfo_width(), 320)
        columns = max(4, min(len(palette_pieces), (canvas_w - 16) // (pw + gap_x)))
        rows = math.ceil(len(palette_pieces) / columns)
        total_w = columns * pw + (columns - 1) * gap_x
        start_x = max((canvas_w - total_w) // 2, 8)
        y0 = self.palette_y

        for i, piece in enumerate(palette_pieces):
            row = i // columns
            col = i % columns
            x0 = start_x + col * (pw + gap_x)
            box_y = y0 + row * (ph + gap_y + 18)
            x1, y1 = x0 + pw, box_y + ph
            self.palette_rects[piece] = (x0, box_y, x1, y1)
            self.canvas.create_rectangle(x0, box_y, x1, y1, outline="#666", width=1, fill="#f7f7f7")
            self.canvas.create_text(x0 + pw / 2, box_y + ph / 2 - 5, text=PIECE_SYMBOLS[piece], font=("Arial", max(int(ph * 0.55), 14)))
            self.canvas.create_text(
                x0 + pw / 2,
                y1 + 10,
                text=piece,
                font=("Arial", 11, "bold"),
                fill="#222",
            )

        if self.drag_piece and self.drag_pos:
            dx, dy = self.drag_pos
            self.canvas.create_text(
                dx,
                dy,
                text=PIECE_SYMBOLS[self.drag_piece],
                font=("Arial", max(int(cell * 0.6), 18)),
                fill="#111",
            )


if __name__ == "__main__":
    random.seed()
    app = ChessTrainer()
    app.mainloop()

:high_voltage: Quick Hits

Want Do
:chess_pawn: Practice coordinates fast → COORDS mode, aim for <2 sec per square
:brain: Train blind calculation → MENTAL mode, verify after each sequence
:bullseye: Learn piece movement → GEOMETRY mode on empty board first
:free_button: No money, no account → Download the .py file, run with Python
:date: More simulators → New one drops every 2 weeks

I’m tired of people being charged unthinkable amounts for basic things. This is free, open-source, and built for everyone. I hope it’s useful to you.

Chess theory without practice is just trivia. Now you’ve got the practice part — for free.

10 Likes

there is no link to chess_trainer.py file

Update info

Thank you this cool work

1 Like

How and where do you download chess_trainer.py?

Sorry, I’m totally clueless with Python.

Thanks for sharing this :saluting_face:

Have you thought about putting the project on GitHub/GitLab? It would make it easier for others to suggest small improvements or share patches