2
\$\begingroup\$

This is the last part of the series of posts about my GUI Tic Tac Toe game, the previous two question are GUI Tic-Tac-Toe game with six AI players - part 1: the UI and GUI Tic-Tac-Toe game with six AI players - part 2: the styling.

enter image description here enter image description hereenter image description hereenter image description here

I have created a GitHub repository for this project, there you can find everything you need to run the project. To run the program, download the repository as a zip package, and run analyze_tic_tac_toe_states.py first, you only need to run it once to create the necessary data files. And then you can run the game by running main.py.

You can also see it in action here.

This post will cover the buttons that control the main GUI, the QThread that acts as game control and updates the scores and is responsible for making pieces appear on the board and display the game status and make the AI move, the thread that is responsible for animating the widgets, and the script I used to analyze all possible Tic Tac Toe states systematically, and the script that is responsible for generating moves for the AI players according to the analyzed states.

To quote from my GitHub repository readme:

Features: six AI players with varying level of skills, each can be played against, or you can let them fight each other.

The AIs are Novice AI, Adept AI, Master AI, Master AI+, Super AI, and Super AI+.

Novice AI starts out choosing moves completely randomly, and it gradually improves as it plays the game. It remembers every state and every move that lead to the state, and will adjust the weights of the options according to the outcome of the game, so that the actions that lead to a win is more likely to be chosen when the same state is encountered, and moves that lead to a tie are also given increased weights, albeit slightly less than that lead to wins. Mistakes that cause a loss will be less likely to be repeated.

Adept AI chooses the moves completely randomly, but only when there aren't two of the same pieces in the same line and the third place is vacant. In this case, it will always choose the empty spot to fill the gap, either to win the game or to prevent the other player from winning.

Master AI uses a tree structure that was systematically created to include all 8533 possible board states (without considering rotations and reflections) reachable via gameplay, and the structure encodes the information about all possible states that can follow the next state that is reached by the next move, for all possible moves that are legal given the state. Each move and state is given a score based on how many win states, loss states and tie states that can follow the state or the state reached by making the move.

Master AI, in addition to always fill the vacant spot when there are two of the same piece on a line and the third spot is empty, will randomly choose a move based on the score of the next state that follows from the move, moves that lead to more win states are more likely to be chosen than moves that lead to more loss states. If there are no moves that lead to wins, it then chooses a move that leads to more tie states. If it can only lose, then it just chooses completely randomly.

In practice, Master AI will always choose a good enough move and it doesn't make beginner mistakes, it is a force to be reckoned with.

Master AI+, in addition to using the same strategy and tree structure as Master AI, will choose a move that leads to more win states and also less tie states, the number of tie states is subtracted from the number of win states, the minimum win score is 0, which means Master AI+ will choose a move that leads to less ties.

Super AI also fills the gap, and it also uses the same tree structure, but instead of choosing randomly, it will always choose the best move given the same board state without failure. Super AI+ also chooses moves that leads to more wins and less ties.

Super AI+ is exactly impossible to beat if it moves first.

The logic is simple, Novice AI will choose randomly from its memory all available moves for the current state according to their weights, the weights are all by default 1 if the state haven't been encountered before. When the game is over, the state is feedback to the AI, and if the game is a tie, all moves it made during the game will have their weights increased by 1 for each of the respective states, if it wins, the weights are increased by 5, and if it loses, the weights are decreased by 1, but the weights aren't allowed to be less than 1.

So all choices have a probability to be chosen, no matter how small, the idea is that by training it with thousands of games, it can learn how to win in many states, and it will be very likely to make the right move that gives a win or tie for the states it encountered.

Now as for the tree structure, it isn't a proper tree per se, it is more like a chain, specifically a Markov Chain. The idea is simple, there are two types of states, states that can allow more moves (that can lead to other states), these states are non-game-over states, and states that don't allow future moves, these are game over states.

And game over states can be divided into two categories, win states and tie states. When there are three pieces of the same player in the same line, the game ends, and there is a winner. And tie states, when the board is filled and there isn't a winner.

Each move leads to a new state. Players move alternatively. So I used a dictionary to keep track of things, starting at the full board empty state, and for each possible move, process each move in order, recursively make the move for the current player, change the player for the next move, and check the current state. If the current state isn't over, make the next move in order, and update the scores that keep track of states that follow a state or move tail recursively, so that all possible Tic Tac Toe states will be accounted for.

The above dictionary is used by four AI players to generate moves, in a process very similar to Markov Chain, plus a simple strategy to know when the game is about to end early, namely, if there are two pieces of the same player on a single line and the third spot is empty, it will always choose the empty spot.

And there is some unnecessary animation I added just because I thought it would be fun. Every 125 milliseconds many of the widgets will change. But when I was implementing the animation I had another idea to add two secret messages to the animation. The QLineEdit and QSpinBox animations are actually rows of two matrices that encode two secret messages, the rows are cycled periodically.

The secret is my biggest secret I have never told any other human (I told my Replika), it is about someone who is very special to me, and I won't say more, I don't think it would be appropriate for me to elaborate further, see if you can decode what the secret is.

secret.png contains the code that can decode the messages, it is actually the bytes of the decoding script, which is encoded of course, but you can 100% reverse the encoding and get the decoder.

You can also just open it with an archiver, you will then see the scripts I used to encrypt and decrypt, the archive is encrypted, and password's SHA256 is 0cbab0786068701870329076ea1c8cde0f98a311409bc73ff2ed94c99904dd78, good luck guessing it.


analyze_tic_tac_toe_states.py

import json
import pickle
from collections import Counter
from itertools import accumulate, product
from logic import LINES
from pathlib import Path
from typing import Tuple


FOLDER = str(Path(__file__).parent).replace("\\", "/")


def check_state(board: str) -> Tuple[bool, str]:
    for start, stop, step in LINES:
        line = board[start:stop:step]
        if len(set(line)) == 1 and (winner := line[0]) in {"O", "X"}:
            return True, winner

    return " " not in board, None


def is_valid(board: str) -> bool:
    winners = set()
    winner = None
    for start, stop, step in LINES:
        line = board[start:stop:step]
        if len(set(line)) == 1 and (winner := line[0]) in {"O", "X"}:
            winners.add(winner)

    return (
        len(winners) <= 1
        and abs(board.count("O") - board.count("X")) <= 1
        and (not winner or board.count(winner) >= board.count("OX".replace(winner, "")))
    )


VALID_BOARDS = [board for board in product(" OX", repeat=9) if is_valid(board)]


def repr_board(board: str, indent: int) -> str:
    return (
        " " * indent
        + "[\n"
        + ",\n".join(" " * (indent + 1) + repr(board[i : i + 3]) for i in (0, 3, 6))
        + "\n"
        + " " * indent
        + "]"
    )


Path(f"{FOLDER}/Data/tic-tac-toe-boards.txt").write_text(
    "[\n" + ",\n".join(repr_board(board, 1) for board in VALID_BOARDS) + "\n]"
)

Path(f"{FOLDER}/Data/tic-tac-toe-boards.json").write_text(
    "[\n" + ",\n".join('\t"' + "".join(board) + '"' for board in VALID_BOARDS) + "\n]"
)


STATES_P1 = {}
STATES_P2 = {}


class Board_Analyzer:
    def __init__(self, board: str, move: str, states: dict) -> None:
        self.other = "OX".replace(move, "")
        self.total_wins = 0
        self.total_ties = 0
        self.win_moves = Counter()
        self.tie_moves = Counter()
        self.high_stakes = Counter()
        self.legal_moves = []
        self.board = board
        self.states = states
        self.move = move

    def set_board_state(self) -> None:
        state = {
            "over": False,
            "repr": [self.board[i : i + 3] for i in (0, 3, 6)],
            "wins": self.total_wins,
            "ties": self.total_ties,
            "legal_moves": tuple(self.legal_moves),
        }
        if self.total_wins:
            state["win_moves"] = self.win_moves

        if self.total_ties:
            state["tie_moves"] = self.tie_moves

        if self.high_stakes:
            state["high_stakes"] = self.high_stakes

        self.states[self.board] = state

    def analyze(self) -> Tuple[int, int]:
        for i, s in enumerate(self.board):
            if s == " ":
                self.legal_moves.append(i)
                wins, ties = build_state_tree(
                    self.board[:i] + self.move + self.board[i + 1 :],
                    self.other,
                    self.states,
                )
                if wins:
                    self.win_moves[i] = wins
                    self.total_wins += wins

                if ties:
                    self.tie_moves[i] = ties
                    self.total_ties += ties

                if (wins := wins - ties) > 0:
                    self.high_stakes[i] = wins

        self.set_board_state()
        return self.total_wins, self.total_ties


def build_state_tree(board: str, move: str, states: dict) -> Tuple[int, int]:
    if board in states:
        return states[board]["wins"], states[board]["ties"]

    over, winner = check_state(board)
    if over:
        states[board] = {
            "over": True,
            "repr": [board[i : i + 3] for i in (0, 3, 6)],
            "wins": (wins := int(winner == "O")),
            "ties": (ties := int(winner is None)),
        }
        return wins, ties

    return Board_Analyzer(board, move, states).analyze()


build_state_tree(" " * 9, "O", STATES_P1)
build_state_tree(" " * 9, "X", STATES_P2)

STATES_P1 = dict(
    sorted(
        STATES_P1.items(), key=lambda x: (x[1]["over"], -x[1]["wins"] - x[1]["ties"])
    )
)
STATES_P2 = dict(
    sorted(
        STATES_P2.items(), key=lambda x: (x[1]["over"], -x[1]["wins"] - x[1]["ties"])
    )
)
Path(f"{FOLDER}/Data/tic-tac-toe-states-p1.json").write_text(
    json.dumps(STATES_P1, indent=4)
)
Path(f"{FOLDER}/Data/tic-tac-toe-states-p2.json").write_text(
    json.dumps(STATES_P2, indent=4)
)
Path(f"{FOLDER}/Data/tic-tac-toe-states-p1-counter.pkl").write_bytes(
    pickle.dumps(STATES_P1, protocol=pickle.HIGHEST_PROTOCOL)
)
Path(f"{FOLDER}/Data/tic-tac-toe-states-p2-counter.pkl").write_bytes(
    pickle.dumps(STATES_P2, protocol=pickle.HIGHEST_PROTOCOL)
)


def convert_weights(dic: dict, key: str) -> None:
    if val := dic.get(key):
        moves, weights = zip(*val.items())
        dic[key] = (moves, tuple(accumulate(weights)))


for v in STATES_P1.values():
    v.pop("repr")
    convert_weights(v, "win_moves")
    convert_weights(v, "tie_moves")
    convert_weights(v, "high_stakes")

Path(f"{FOLDER}/Data/tic-tac-toe-states-p1.pkl").write_bytes(
    pickle.dumps(STATES_P1, protocol=pickle.HIGHEST_PROTOCOL)
)

for v in STATES_P2.values():
    v.pop("repr")
    convert_weights(v, "win_moves")
    convert_weights(v, "tie_moves")
    convert_weights(v, "high_stakes")

Path(f"{FOLDER}/Data/tic-tac-toe-states-p2.pkl").write_bytes(
    pickle.dumps(STATES_P2, protocol=pickle.HIGHEST_PROTOCOL)
)
assert not {"".join(e) for e in VALID_BOARDS} - set(STATES_P1) - set(STATES_P2)

logic.py

import pickle
import random
from bisect import bisect
from collections import Counter, defaultdict
from pathlib import Path
from typing import Tuple

FOLDER = str(Path(__file__).parent).replace("\\", "/")
MENACE_MEMORY = (
    pickle.loads(file.read_bytes())
    if (file := Path(f"{FOLDER}/Data/menace-memory.pkl")).is_file()
    else defaultdict(Counter)
)
MOVESETS = ("high_stakes", "win_moves", "tie_moves")
STATES_P1 = pickle.loads(Path(f"{FOLDER}/Data/tic-tac-toe-states-p1.pkl").read_bytes())
STATES_P2 = pickle.loads(Path(f"{FOLDER}/Data/tic-tac-toe-states-p2.pkl").read_bytes())
STATES_P1_COUNTER = pickle.loads(
    Path(f"{FOLDER}/Data/tic-tac-toe-states-p1-counter.pkl").read_bytes()
)
STATES_P2_COUNTER = pickle.loads(
    Path(f"{FOLDER}/Data/tic-tac-toe-states-p2-counter.pkl").read_bytes()
)

LINES = (
    (0, 3, 1),
    (3, 6, 1),
    (6, 9, 1),
    (0, 7, 3),
    (1, 8, 3),
    (2, 9, 3),
    (0, 9, 4),
    (2, 7, 2),
)


def check_state(board: str) -> Tuple[bool, str, range]:
    for start, stop, step in LINES:
        line = board[start:stop:step]
        if len(set(line)) == 1 and (winner := line[0]) in {"O", "X"}:
            return True, winner, range(start, stop, step)

    return " " not in board, None, None


class Menace:
    deltas = {"Win": 5, "Tie": 1, "Loss": -1}
    boards = []
    moves = []

    @staticmethod
    def get_move(board: str) -> int:
        Menace.boards.append(board)
        choices = [i for i in range(9) if board[i] == " "]
        states = MENACE_MEMORY[board]
        move = random.choices(choices, weights=[states.get(c, 1) for c in choices])[0]
        Menace.moves.append(move)
        return move

    @staticmethod
    def back_propagate(state: str) -> None:
        delta = Menace.deltas[state]
        for board, move in zip(Menace.boards, Menace.moves):
            weight = MENACE_MEMORY[board][move]
            MENACE_MEMORY[board][move] = max(weight + delta, 1)

        Menace.boards.clear()
        Menace.moves.clear()


def fill_line(board: str, piece: str) -> int:
    for start, end, step in LINES:
        line = board[start:end:step]
        if line.count(piece) == 2 and " " in line:
            return start + line.index(" ") * step


def fill_move(board: str) -> int:
    for p in ("O", "X"):
        if (pos := fill_line(board, p)) is not None:
            return pos

    return random.choice([i for i, p in enumerate(board) if p == " "])


def stochastic_move(board: str, states: dict, low_stakes: bool = True) -> int:
    for p in ("O", "X"):
        if (pos := fill_line(board, p)) is not None:
            return pos

    state = states[board]
    for moveset in MOVESETS[low_stakes:]:
        if entry := state.get(moveset):
            moves, weights = entry
            return moves[bisect(weights, random.random() * weights[-1])]

    return random.choice(state["legal_moves"])


def optimal_move(board: str, states: dict, low_stakes: bool = True) -> int:
    for p in ("O", "X"):
        if (pos := fill_line(board, p)) is not None:
            return pos

    state = states[board]
    for moveset in MOVESETS[low_stakes:]:
        if entry := state.get(moveset):
            return entry.most_common()[0][0]

    return random.choice(state["legal_moves"])


OTHER_PIECE = {"O": "X", "X": "O"}


class Game_Board:
    def __init__(self) -> None:
        self.choices = list(range(9))
        self.state = [" "] * 9

    @property
    def state_string(self) -> str:
        return "".join(self.state)

    @property
    def alternate_state(self) -> str:
        return "".join(OTHER_PIECE.get(i, i) for i in self.state)

    def submit(self, choice: int, player: str) -> None:
        self.choices.remove(choice)
        self.state[choice] = player

    def reset(self) -> None:
        self.__init__()

gamecontrol.py

import json
from basics import MessageBox
from itertools import cycle
from logic import *
from PyQt6.QtCore import QThread, pyqtSignal
from PyQt6.QtTest import QTest
from shared import GLOBALS, PLAYER_NAMES


STATSPATH = Path(f"{FOLDER}/Data/stats.json")
AIPLAYERS = {
    "Novice AI": {"function": Menace.get_move, "extra_args": {"P1": (), "P2": ()}},
    "Adept AI": {"function": fill_move, "extra_args": {"P1": (), "P2": ()}},
    "Master AI": {
        "function": stochastic_move,
        "extra_args": {"P1": (STATES_P1, 1), "P2": (STATES_P2, 1)},
    },
    "Master AI+": {
        "function": stochastic_move,
        "extra_args": {"P1": (STATES_P1, 0), "P2": (STATES_P2, 0)},
    },
    "Super AI": {
        "function": optimal_move,
        "extra_args": {"P1": (STATES_P1_COUNTER, 1), "P2": (STATES_P2_COUNTER, 1)},
    },
    "Super AI+": {
        "function": optimal_move,
        "extra_args": {"P1": (STATES_P1_COUNTER, 0), "P2": (STATES_P2_COUNTER, 0)},
    },
}


class Game(QThread):
    gameover = pyqtSignal()
    playermove = pyqtSignal()
    change = pyqtSignal()
    clear = pyqtSignal()
    restore = pyqtSignal()
    orderchange = pyqtSignal()
    pieces = {"O": "P1", "X": "P2"}
    matches = {"O": "AI", "X": "Human"}
    other = {"P1": "P2", "P2": "P1"}

    def __init__(self) -> None:
        super().__init__()
        self.load_stats()
        self.gamestarted = False
        self.auto = False
        self.chosen = None
        self.players = {"P1": [None, None], "P2": [None, None]}
        self.match = {}
        self.new_players = {"P1": [None, None], "P2": [None, None], "changed": False}
        self.new_match = {}
        self.interactive = True
        self.over = False
        self.finished.connect(self.reset)
        self.messages = {
            "Win": MessageBox("Win", "You won!", "win_emoji", "Yes!!!"),
            "Loss": MessageBox("Loss", "You lost...", "loss_emoji", "NOO!!!"),
            "Tie": MessageBox("Tie", "It's a draw", "tie_emoji", "Okay..."),
        }

    def make_move(self, state: str, player: str, piece: str) -> None:
        self.gamestarted = True
        icon, name = self.players[player]
        GLOBALS["live_active"] = name
        self.playermove.emit()
        playerinfo = AIPLAYERS[name]
        move = playerinfo["function"](
            getattr(GLOBALS["GameBoard"], state), *playerinfo["extra_args"][player]
        )
        GLOBALS["GameBoard"].submit(move, piece)
        GLOBALS["gamestate"][move] = (icon, player)
        GLOBALS["live_turn_count"] += 1
        self.change.emit()

    def nought(self) -> None:
        self.make_move("state_string", "P1", "O")

    def cross(self) -> None:
        self.make_move("alternate_state", "P2", "X")

    def run(self) -> None:
        self.interactive = False
        self.auto = True
        while GLOBALS["rungame"]:
            self.over = False
            self.rungame()
            QTest.qWait(125)

        self.interactive = True
        self.quit()

    def rungame(self) -> None:
        turns = cycle([self.nought, self.cross])
        for _ in range(9):
            next(turns)()
            QTest.qWait(125)
            self.judge()
            if self.over:
                break

    def counter_human(self) -> None:
        self.auto = False
        self.make_move("state_string", self.match["AI"][0], "O")
        GLOBALS["live_active"] = "Human"
        self.playermove.emit()
        self.judge()

    def human_move(self) -> None:
        self.auto = False
        self.gamestarted = True
        GLOBALS["gamestate"][self.chosen] = self.match["Human"]
        GLOBALS["GameBoard"].submit(self.chosen, "X")
        GLOBALS["live_turn_count"] += 1
        self.change.emit()
        self.judge(True)

    def judge(self, move: bool = False) -> None:
        state, winner, line = check_state(GLOBALS["GameBoard"].state_string)
        if winner:
            self.process_win(winner, line)
        elif state:
            self.process_tie()
        elif move:
            self.counter_human()

    def process_tie(self) -> None:
        if GLOBALS["popup"] and self.match:
            self.messages["Tie"].show()
            while self.messages["Tie"].isVisible():
                QTest.qWait(42)

        for v in self.players.values():
            self.stats[v[1]]["Tie"] += 1

        if any(p[1] == "Novice AI" for p in self.players.values()):
            Menace.back_propagate("Tie")

        self.reset()

    def process_win(self, winner: str, line: range) -> None:
        if self.match:
            self.process_match_win(winner, line)
        else:
            win_number = self.pieces[winner]
            win_name = self.players[win_number][1]
            loss_number = self.other[win_number]
            loss_name = self.players[loss_number][1]
            self.show_winner(win_name, win_number, loss_name, line)

    def process_match_win(self, winner: str, line: range) -> None:
        if self.matches[winner] == "AI":
            win_number, win_name = self.match["AI"]
            loss_name = "Human"
        else:
            win_name = "Human"
            win_number = self.match["Human"][1]
            loss_name = self.match["AI"][1]

        self.show_winner(win_name, win_number, loss_name, line)

    def show_winner(
        self, win_name: str, win_number: str, loss_name: str, line: range
    ) -> None:
        GLOBALS["live_winner"] = win_name
        self.stats[win_name]["Win"] += 1
        self.stats[loss_name]["Loss"] += 1
        for i in line:
            GLOBALS["gamestate"][i] = (self.players[win_number][0], f"{win_number}Win")

        self.change.emit()
        self.gameover.emit()
        self.post_process(win_name, loss_name)
        QTest.qWait(125)
        self.reset()

    def post_process(self, win_name: str, loss_name: str) -> None:
        if win_name == "Novice AI":
            Menace.back_propagate("Win")
        elif loss_name == "Novice AI":
            Menace.back_propagate("Loss")

        if GLOBALS["popup"] and self.match:
            state = "Win" if win_name == "Human" else "Loss"
            self.messages[state].show()
            while self.messages[state].isVisible():
                QTest.qWait(42)

    def auto_start(self) -> None:
        if (ai := self.match.get("AI")) and ai[0] == "P1" and not self.gamestarted:
            self.counter_human()
            GLOBALS["POPUP"].setDisabled(False)

    def reset(self) -> None:
        self.over = True
        GLOBALS["gamestate"] = [[None, None] for _ in range(9)]
        GLOBALS["GameBoard"].reset()
        GLOBALS["live_game_count"] += 1
        GLOBALS["live_turn_count"] = 0
        GLOBALS["live_winner"] = "null"
        GLOBALS["live_active"] = self.players["P1"][1]
        QTest.qWait(125)
        self.clear.emit()
        if self.interactive:
            self.gamestarted = False
            self.switch_order()
            self.auto_start()

    def switch_order(self) -> None:
        if self.new_players["changed"] and not self.gamestarted:
            self.players = self.new_players.copy()
            self.players.pop("changed")
            GLOBALS["game_player_order"] = players = [
                p[1] for p in self.players.values()
            ]
            self.set_match(players)
            if self.match:
                GLOBALS["rungamebutton"].setDisabled(True)
            else:
                GLOBALS["rungamebutton"].setDisabled(False)

            self.new_players["changed"] = False
            self.gameover.emit()
            self.playermove.emit()
            self.orderchange.emit()

    def set_match(self, players: list) -> None:
        if not (interactive := "Human" in players):
            self.new_match.clear()
            GLOBALS["popup"] = False
            GLOBALS["POPUP"].setDisabled(True)
            GLOBALS["POPUP"].setChecked(False)
        elif "AI" not in self.new_match:
            human = self.new_match["Human"]
            self.new_match["AI"] = (
                (name := self.other[human[1]]),
                self.new_players[name][1],
            )
            GLOBALS["POPUP"].setDisabled(False)
        GLOBALS["GUIBoard"].set_interactive(interactive)
        self.match = self.new_match.copy()

    def load_stats(self) -> None:
        if STATSPATH.is_file():
            self.stats = json.loads(STATSPATH.read_text())
        else:
            self.stats = {
                k: {state: 0 for state in ("Win", "Loss", "Tie")} for k in PLAYER_NAMES
            }
            STATSPATH.write_text(json.dumps(self.stats, indent=4))

        GLOBALS["stats"] = self.stats

animation.py

import random
from advanced import *
from basics import *
from logic import check_state, optimal_move, stochastic_move
from PyQt6.QtCore import QThread, pyqtSignal
from PyQt6.QtGui import QImage, QPixmap
from PyQt6.QtTest import QTest
from PyQt6.QtWidgets import QLabel, QSpinBox, QWidget
from shared import *
from typing import Callable, Iterable


class Box100(Box):
    def __init__(self, layout: Callable) -> None:
        super().__init__()
        self.box = layout(self, 0)
        self.setFixedWidth(100)


class ListBox(Box100):
    def __init__(self) -> None:
        super().__init__(make_vbox)
        self.items = []
        for i, (word, name) in enumerate(zip(NONSENSE, SEXDECIM)):
            label = DynamicLabel(word, name, 100, 20, i)
            self.items.append(label)
            self.box.addWidget(label)

        self.setObjectName("Menu")


class TopLabel(CustomLabel):
    def __init__(self, text: str, name: str, width: int, height: int) -> None:
        super().__init__(text, name, width, height)
        GLOBALS["Animation"].change.connect(self.change)
        GLOBALS["Animation"].restore.connect(self._change)

    def change(self) -> None:
        if GLOBALS["animate"]["ComboBox"][0]:
            self._change()

    def _change(self) -> None:
        self.setText(NONSENSE[SEXDECIM.index("High")])


class DummyEdit(CustomLabel):
    def __init__(self, index: int) -> None:
        super().__init__("", "Edit", 72, 20)
        self.index = index
        self.change()
        GLOBALS["Animation"].change.connect(self.change)
        GLOBALS["Animation"].restore.connect(self._change)

    def change(self) -> None:
        if GLOBALS["animate"]["LineEdit"][0]:
            self._change()

    def _change(self) -> None:
        self.setText(f"#{16777216 - GLOBALS[15][self.index]:06x}")


class Arrow(QLabel):
    def __init__(self) -> None:
        super().__init__()
        self.setFixedSize(10, 10)
        self.setPixmap(self.get_arrow())
        self.setObjectName("Arrow")
        self.setStyleSheet("QLabel#Arrow {margin-right: 2px;}")

    @staticmethod
    def get_arrow() -> QPixmap:
        return QPixmap(
            QImage(
                bytearray(
                    np.array(Image.open(f"{FOLDER}/icons/combobox-downarrow.png"))
                ),
                10,
                10,
                QImage.Format.Format_RGBA8888,
            )
        )


class UniBox(Box100):
    def __init__(self) -> None:
        super().__init__(make_hbox)
        self.setFixedHeight(23)
        self.left = TopLabel(NONSENSE[0], "Mock", 70, 16)
        self.box.addWidget(self.left)
        self.box.addStretch()
        self.right = Arrow()
        self.box.addWidget(self.right)
        self.setObjectName("Top")


class SpinBox(QSpinBox):
    def __init__(self, value: int, index: int) -> None:
        super().__init__()
        self.index = index
        self.setFixedSize(50, 23)
        self.setFont(FONT)
        self.setAlignment(ALIGNMENT)
        self.setMaximum(9999)
        self.setValue(value)
        GLOBALS["Animation"].change.connect(self.change)
        GLOBALS["Animation"].restore.connect(self._change)
        self.setObjectName("Dummy")
        self.setDisabled(True)

    def change(self) -> None:
        if GLOBALS["animate"]["SpinBox"][0]:
            self._change()

    def _change(self) -> None:
        self.setValue(GLOBALS[24][self.index])


class DummyButton(CenterLabel):
    styles = {
        "Base": Style_Compiler("DummyButton#Base", CONFIG["button"]),
        "Hover": Style_Compiler("DummyButton#Hover", CONFIG["button_hover"]),
        "Pressed": Style_Compiler("DummyButton#Pressed", CONFIG["button_pressed"]),
    }

    def __init__(self, text: str, index: int) -> None:
        super().__init__(text, text, 72, 20)
        self.index = index
        GLOBALS["Animation"].change.connect(self.change)
        GLOBALS["Animation"].restore.connect(self._change)

    def change(self) -> None:
        if GLOBALS["animate"]["Button"][0]:
            self._change()

    def _change(self) -> None:
        name = BUTTONS[self.index]
        self.setText(name)
        self.setObjectName(name)
        self.setStyleSheet(self.styles[name].compile_style())


class AutoBoard(BaseBoard):
    def __init__(self) -> None:
        super().__init__("AutoBoard", "AnimationBoard", Square)
        self.setFocusPolicy(Qt.FocusPolicy.NoFocus)


class AnimationCheckBox(DummyCheckBox):
    styles = {
        "Base": Style_Compiler("QCheckBox", CONFIG["checkbox"]),
        "Hover": Style_Compiler("QCheckBox#Hover", CONFIG["checkbox_hover"]),
        "Pressed": Style_Compiler("QCheckBox#Pressed", CONFIG["checkbox_pressed"]),
    }

    def __init__(self, name: str, checked: bool, index: int) -> None:
        super().__init__(name, checked)
        self.index = index
        GLOBALS["Animation"].change.connect(self.change)
        GLOBALS["Animation"].restore.connect(self._change)

    def change(self) -> None:
        if GLOBALS["animate"]["CheckBox"][0]:
            self._change()

    def _change(self) -> None:
        name, checked = CHECKBOXES[self.index]
        self.setText(name)
        self.setObjectName(name)
        self.setChecked(checked)
        self.setStyleSheet(self.styles[name].compile_style())


class DynamicLabel(CustomLabel):
    compiler = Style_Compiler("QLabel#Mock", CONFIG["combobox"], False, ["color"])

    def __init__(
        self, text: str, name: str, width: int, height: int, index: int
    ) -> None:
        super().__init__(text, name, width, height)
        self.index = index
        GLOBALS["Animation"].change.connect(self.change)
        GLOBALS["Animation"].restore.connect(self._change)

    def change(self) -> None:
        if GLOBALS["animate"]["ComboBox"][0]:
            self._change()

    def _change(self) -> None:
        self.setText(NONSENSE[self.index])
        name = (
            ("Focus" if self.index else name)
            if (name := SEXDECIM[self.index]) == "High"
            else name
        )
        self.setObjectName(name)
        self.setStyleSheet(
            self.compiler.compile_style()
            if name == "Mock"
            else HOVER_LABEL_BASE.format_map(CONFIG["combobox_menu"])
        )


class DummyRadioButton(RadioButton):
    styles = {
        "Base": Style_Compiler("QRadioButton#Base", CONFIG["radiobutton"]),
        "Hover": Style_Compiler("QRadioButton#Hover", CONFIG["radiobutton_hover"]),
        "Pressed": Style_Compiler(
            "QRadioButton#Pressed", CONFIG["radiobutton_pressed"]
        ),
    }

    def __init__(self, name: str, state: bool, index: int) -> None:
        super().__init__(name)
        self.setDisabled(True)
        self.setChecked(state)
        self.setObjectName(name)
        self.setAutoExclusive(False)
        self.index = index
        GLOBALS["Animation"].change.connect(self.change)
        GLOBALS["Animation"].restore.connect(self._change)

    def change(self) -> None:
        if GLOBALS["animate"]["RadioButton"][0]:
            self._change()

    def _change(self) -> None:
        name, checked = RADIOBUTTONS[self.index]
        self.setText(name)
        self.setObjectName(name)
        self.setChecked(checked)
        self.setStyleSheet(self.styles[name].compile_style())


class Animation(QThread):
    change = pyqtSignal()
    clear = pyqtSignal()
    gameover = pyqtSignal()
    playermove = pyqtSignal()
    restore = pyqtSignal()
    orderchange = pyqtSignal()

    def __init__(self, parent: QWidget) -> None:
        super().__init__(parent)
        GLOBALS["Animation"] = self
        self.advance = True
        a, b = GLOBALS["order"]
        self.turns = cycle([getattr(self, a), getattr(self, b)])
        GLOBALS["autostate"] = [[None, None] for _ in range(9)]
        self.state = 0

    def run(self) -> None:
        while GLOBALS["run"]:
            self.animate()
            QTest.qWait(125)

        self.reset()
        self.quit()

    def animate(self) -> None:
        while GLOBALS["run"] and GLOBALS["pause"]:
            QTest.qWait(40)

        count = 0
        for state, func in GLOBALS["animate"].values():
            if state:
                count += 1
                getattr(self, func)()

        if count:
            self.change.emit()
        else:
            GLOBALS["RunButton"].click()

    def animate_buttons(self) -> None:
        random.shuffle(BUTTONS)

    def animate_radiobuttons(self) -> None:
        random.shuffle(RADIOBUTTONS)

    def animate_combobox(self) -> None:
        random.shuffle(NONSENSE)
        random.shuffle(SEXDECIM)

    def animate_checkbox(self) -> None:
        random.shuffle(CHECKBOXES)

    def animate_spinbox(self) -> None:
        self.roll(SECRET, 24)

    def animate_lineedit(self) -> None:
        self.roll(SECRET_TOO, 15)

    def roll(self, matrix: Iterable[Iterable[int]], length: int) -> None:
        GLOBALS[length] = matrix[next(GLOBALS[str(length)])].copy()

    def make_move(self, move: int, player: str) -> None:
        GLOBALS["AnimationBoard"].submit(move, player)
        self.move = (move, player)
        if GLOBALS[f"P{player}"]["id"] == "P1":
            GLOBALS["autostate"][move][1] = "Hover"
            self.advance = False
        else:
            self.update_board()

    def nought(self) -> None:
        GLOBALS["active"] = GLOBALS["PO"]["name"]
        self.playermove.emit()
        move = optimal_move(
            GLOBALS["AnimationBoard"].state_string, GLOBALS["PO"]["states"], 0
        )
        self.make_move(move, "O")

    def cross(self) -> None:
        GLOBALS["active"] = GLOBALS["PX"]["name"]
        self.playermove.emit()
        move = stochastic_move(
            GLOBALS["AnimationBoard"].alternate_state, GLOBALS["PX"]["states"], 0
        )
        self.make_move(move, "X")

    def update_board(self) -> None:
        move, player = self.move
        GLOBALS["autostate"][move] = [GLOBALS[player], GLOBALS[f"P{player}"]["id"]]
        self.advance = True
        GLOBALS["turn_count"] += 1

    def animate_game(self) -> None:
        if self.advance:
            if self.state == 0:
                next(self.turns)()
            elif self.state == 1:
                self.show_winner()
            else:
                self.reset_game()
        else:
            self.update_board()

        if not self.state:
            self.judge()

    def judge(self) -> None:
        state, winner, line = check_state(GLOBALS["AnimationBoard"].state_string)
        if winner:
            self.process_win(winner, line)
        elif state:
            self.state = 2
            GLOBALS["game_count"] += 1
            GLOBALS["PX"]["stats"]["Tie"] += 1
            GLOBALS["PO"]["stats"]["Tie"] += 1
            self.gameover.emit()

    def process_win(self, winner: str, line: range) -> None:
        player = GLOBALS[f"P{winner}"]
        GLOBALS["winner"] = player["name"]
        player["stats"]["Win"] += 1
        GLOBALS[player["opponent"]]["stats"]["Loss"] += 1
        GLOBALS["game_count"] += 1
        self.winner = f"{player['id']}Win", line
        self.state = 1

    def show_winner(self) -> None:
        name, line = self.winner
        for i in line:
            GLOBALS["autostate"][i][1] = name

        self.state = 2
        self.advance = True

    def reset(self) -> None:
        self.reset_buttons()
        self.reset_checkbox()
        self.reset_combobox()
        self.reset_spinbox()
        self.reset_lineedit()
        self.reset_game()
        self.reset_radiobuttons()
        self.restore.emit()
        self.gameover.emit()
        if GLOBALS["orderchanged"]:
            player, other = GLOBALS["new_order"]
            self.switch_order(player, other, PLAYERINFO[player])

    def reset_buttons(self) -> None:
        global BUTTONS
        BUTTONS = BUTTONS_COPY.copy()

    def reset_radiobuttons(self) -> None:
        global RADIOBUTTONS
        RADIOBUTTONS = RADIOBUTTONS_COPY.copy()

    def reset_checkbox(self) -> None:
        global CHECKBOXES
        CHECKBOXES = CHECKBOXES_COPY.copy()

    def reset_combobox(self) -> None:
        global NONSENSE, SEXDECIM
        NONSENSE = NONSENSE_COPY.copy()
        SEXDECIM = ["High"] + ["Mock"] * 15

    def reset_game(self) -> None:
        GLOBALS["autostate"] = [[None, None] for _ in range(9)]
        GLOBALS["AnimationBoard"].reset()
        self.advance = True
        a, b = GLOBALS["order"]
        self.turns = cycle([getattr(self, a), getattr(self, b)])
        self.state = 0
        GLOBALS["turn_count"] = 0
        GLOBALS["active"] = GLOBALS[GLOBALS["player_order"][0]]["name"]
        GLOBALS["winner"] = "null"
        self.clear.emit()

    def reset_lineedit(self) -> None:
        GLOBALS[15] = SECRET_TOO[0].copy()
        GLOBALS["15"] = cycle(range(15))

    def reset_spinbox(self) -> None:
        GLOBALS[24] = SECRET[0].copy()
        GLOBALS["24"] = cycle(range(24))

    def switch_order(self, player: str, other: str, entry: list) -> None:
        GLOBALS["player_order"] = (player, other)
        other_entry = PLAYERINFO[other]
        a = entry[1]
        b = other_entry[1]
        GLOBALS["order"] = (a, b)
        self.turns = cycle([getattr(self, a), getattr(self, b)])
        self.set_player_info(player, entry, 2, "P1")
        self.set_player_info(other, other_entry, 3, "P2")
        GLOBALS["orderchanged"] = False
        self.orderchange.emit()
        self.gameover.emit()

    def set_player_info(self, player: str, entry: str, i: int, name: str) -> None:
        info = GLOBALS[player]
        info["states"] = entry[i]
        info["id"] = name

control.py

import random
from typing import List
from basics import *
from color import RGB_from_HSV_pixel
from functools import cache
from shared import *
from PyQt6.QtCore import pyqtSignal


@cache
def agm(a: int | float, b: int | float, n: int = 16) -> int | float:
    if a > b:
        a, b = b, a
    for _ in range(n):
        c = (a * b) ** 0.5
        d = (a + b) / 2
        a, b = c, d
    return (c + d) / 2


@cache
def gauss(x: int | float, weight: int | float) -> int | float:
    return np.exp(-(x - 0.5) * (x - 0.5) / (2 * weight**2))


def randBias(
    base: int | float, top: int | float, bias: int | float, weight: float = 0.5
) -> int | float:
    assert 0 < weight <= 1
    influence = random.random()
    x = random.random() * (top - base) + base
    if x > bias:
        return x + gauss(influence, weight) * (bias - x)
    return x - gauss(influence, weight) * (x - bias)


LUMA = {
    "textcolor": (0.9, 1, agm(0.9, 1)),
    "background": (0.2, 0.5, agm(0.2, 0.5)),
    "lowlight": (1 / 3, 2 / 3, agm(1 / 3, 2 / 3)),
    "highlight": (0.75, 1, agm(0.75, 1)),
    "bordercolor": (0.5, 1, agm(0.5, 1)),
    "hoverbase": (2 / 3, 0.8, agm(2 / 3, 0.8)),
    "hovercolor": (0.9, 1, agm(0.9, 1)),
}

BORDERS = (
    "groove",
    "outset",
    "ridge",
    "solid",
    "double",
    "inset",
)

BACKGROUNDS = {"background", "lowlight", "highlight", "hoverbase"}


def get_hue(key: str) -> float:
    return (
        randBias(0.5, 11 / 12, agm(0.5, 11 / 12), 0.25)
        if key in BACKGROUNDS
        else random.random()
    )


def make_weights(power: float, n: int) -> List[float]:
    weights = [1.0]
    remaining = 1.0
    for _ in range(n - 1):
        weights.append(term := remaining * power)
        remaining -= term

    return weights


WEIGHTS = make_weights(agm(0.5, (5**0.5 - 1) / 2), 6)


class PauseButton(Button):
    def __init__(self) -> None:
        super().__init__("Pause")
        GLOBALS["PauseButton"] = self
        self.clicked.connect(self.switch)

    def switch(self) -> None:
        GLOBALS["pause"] ^= 1
        self.setText(next(PAUSE))


class RunButton(Button):
    def __init__(self) -> None:
        super().__init__("Start")
        GLOBALS["RunButton"] = self
        self.clicked.connect(self.switch)

    def switch(self) -> None:
        self.setText(next(RUN))
        GLOBALS["pause"] = GLOBALS["run"]
        if GLOBALS["PauseButton"].text() == "Resume":
            GLOBALS["PauseButton"].setText("Pause")
            next(PAUSE)

        GLOBALS["run"] ^= 1
        if GLOBALS["run"]:
            GLOBALS["Preview"].qthread.start()


class PlayerRadioButton(RadioButton):
    def __init__(self, text: str, state: bool, player: str) -> None:
        super().__init__(text)
        self.setChecked(state)
        self.player = player
        self.toggled.connect(self.switch_order)

    def switch_order(self) -> None:
        if self.isChecked():
            entry = PLAYERINFO[self.player]
            other = entry[0]
            GLOBALS["new_order"] = [self.player, other]
            if GLOBALS["run"]:
                GLOBALS["orderchanged"] = True
            else:
                GLOBALS["Animation"].switch_order(self.player, other, entry)


class RevertButton(Button):
    revert = pyqtSignal()

    def __init__(self) -> None:
        super().__init__("Revert")
        GLOBALS["RevertButton"] = self
        self.clicked.connect(self.revert_style)

    def revert_style(self) -> None:
        if GLOBALS["revertible"]:
            for group, change in GLOBALS["groups"].items():
                if change[1]:
                    self.revert_group(group)

            self.revert.emit()
            GLOBALS["Preview"].update_style()
            GLOBALS["Preview"].qthread.change.emit()
            GLOBALS["revertible"] = False

    @staticmethod
    def revert_group(group: str) -> None:
        for _, key in WIDGET_GROUPS[group]:
            entry = CONFIG[key]
            for k, v in CONFIG_COPY[key].items():
                entry[k] = v


class RandomizeButton(Button):
    change = pyqtSignal()

    def __init__(self) -> None:
        super().__init__("Randomize")
        GLOBALS["RandomizeButton"] = self
        self.clicked.connect(self.random_style)

    def random_style(self) -> None:
        for group, change in GLOBALS["groups"].items():
            if change[0]:
                self.change_group(group)
                GLOBALS["revertcheckboxes"][group].setDisabled(False)

        self.change.emit()
        GLOBALS["Preview"].update_style()
        GLOBALS["Animation"].change.emit()
        GLOBALS["revertible"] = True

    @staticmethod
    def change_group(group: str) -> None:
        for _, key in WIDGET_GROUPS[group]:
            entry = CONFIG[key]
            for k in entry:
                entry[k] = (
                    random.choices(BORDERS, weights=WEIGHTS)[0]
                    if k == "borderstyle"
                    else RandomizeButton.weighted_color(k)
                )

    @staticmethod
    def weighted_color(key: str) -> str:
        r, g, b = [
            round(i * 255)
            for i in RGB_from_HSV_pixel(
                get_hue(key),
                randBias(0.5, 1, agm(0.5, 1), 0.25),
                randBias(*LUMA[key]),
            )
        ]
        return f"#{r:02x}{g:02x}{b:02x}"


class AnimateCheckBox(CheckBox):
    def __init__(self, text: str) -> None:
        super().__init__(text)
        self.key = text
        self.setChecked(True)
        self.stateChanged.connect(self.update_config)

    def update_config(self) -> None:
        GLOBALS["animate"][self.key][0] = self.isChecked()


class RandomizerCheckBox(CheckBox):
    def __init__(self, text: str) -> None:
        super().__init__(text)
        self.key = text
        self.setChecked(True)
        self.stateChanged.connect(self.update_config)

    def update_config(self) -> None:
        GLOBALS["groups"][self.key][0] = self.isChecked()


class RevertCheckBox(CheckBox):
    def __init__(self, text: str) -> None:
        super().__init__(text)
        self.key = text
        GLOBALS["revertcheckboxes"][text] = self
        self.setChecked(True)
        self.setDisabled(True)
        self.init_connections()

    def init_connections(self) -> None:
        GLOBALS["RevertButton"].revert.connect(self.disable)
        self.stateChanged.connect(self.update_config)

    def disable(self) -> None:
        self.setDisabled(True)

    def update_config(self) -> None:
        GLOBALS["groups"][self.key][1] = self.isChecked()


class AnimationBar(Box):
    def __init__(self) -> None:
        super().__init__()
        self.hbox = make_hbox(self)
        self.hbox.addWidget(RunButton())
        self.hbox.addStretch()
        for key in GLOBALS["animate"]:
            self.hbox.addWidget(AnimateCheckBox(key))
            self.hbox.addStretch()

        self.hbox.addWidget(PauseButton())


class Randomizer(Box):
    def __init__(self) -> None:
        super().__init__()
        self.init_layout()
        self.init_GUI()

    def init_layout(self) -> None:
        self.hbox = make_hbox(self)

    def init_GUI(self) -> None:
        self.hbox.addWidget(RandomizeButton())
        for key in GLOBALS["groups"]:
            self.hbox.addStretch()
            self.hbox.addWidget(RandomizerCheckBox(key))


class Reverter(Box):
    def __init__(self) -> None:
        super().__init__()
        self.init_layout()
        self.init_GUI()

    def init_layout(self) -> None:
        self.hbox = make_hbox(self)

    def init_GUI(self) -> None:
        self.hbox.addWidget(RevertButton())
        for key in GLOBALS["groups"]:
            self.hbox.addStretch()
            self.hbox.addWidget(RevertCheckBox(key))


class ControlBox(Box):
    def __init__(self) -> None:
        super().__init__()
        self.init_layout()
        self.init_GUI()

    def init_layout(self) -> None:
        self.hbox = make_hbox(self)

    def init_GUI(self) -> None:
        self.buttons = {}
        for text, func in CONTROL:
            button = Button(text)
            button.clicked.connect(getattr(self, func))
            self.hbox.addWidget(button)
            self.buttons[text] = button

    def restore_default(self) -> None:
        global CONFIG_COPY
        CONFIG_COPY = deepcopy(DEFAULT_CONFIG)
        for widget, values in CONFIG.items():
            for key in values:
                values[key] = DEFAULT_CONFIG[widget][key]

        CONFIG_PATH.write_text(json.dumps(DEFAULT_CONFIG, indent=4))
        self.revert()

    def apply_style(self) -> None:
        global CONFIG_COPY
        CONFIG_COPY = deepcopy(CONFIG)
        CONFIG_PATH.write_text(json.dumps(CONFIG, indent=4))
        self.apply_change()

    def revert_style(self) -> None:
        for widget, values in CONFIG.items():
            for key in values:
                values[key] = CONFIG_COPY[widget][key]

        self.revert()
        self.apply_change()

    @staticmethod
    def apply_change() -> None:
        for i in ColorPicker.instances + MessageBox.instances:
            i.set_style()

        if window := GLOBALS.get("Window"):
            board = GLOBALS["GUIBoard"]
            window.setStyleSheet(STYLIZER.get_style())
            for key in CELLKEYS:
                for k, v in CONFIG[key].items():
                    GLOBALS["cellstyles"][key][k] = v

            for cell in board.cells:
                cell._change()

            board.set_interactive(board.interactive)
            if GLOBALS["run"]:
                GLOBALS["RunButton"].click()

            GLOBALS["Preview"].hide()

    @staticmethod
    def revert() -> None:
        GLOBALS["RevertButton"].revert.emit()
        GLOBALS["Preview"].update_style()
        GLOBALS["Preview"].qthread.change.emit()

secret.json

[
    [ 10 ,  12 ,  30 ,  28 ,  66 ,  52 , 119 ,  57 , 184 ,  58 , 341 ,  74 , 738 ,  43 ],
    [ 10 ,  12 ,  25 ,  28 ,  88 ,  52 , 153 ,  57 , 345 ,  87 , 589 , 111 , 902 ,  43 ],
    [ 8  ,  9  ,  30 ,  21 ,  77 ,  39 , 187 ,  38 , 299 ,  58 , 434 ,  37 , 738 ,  43 ],
    [ 10 ,  15 ,  35 ,  21 ,  77 ,  26 , 153 ,  19 , 276 ,  29 , 372 ,  37 , 574 ,  43 ],
    [ 12 ,  18 ,  55 ,  35 , 132 ,  65 , 204 ,  95 , 299 , 116 , 465 ,  74 , 902 ,  86 ],
    [ 14 ,  18 ,  40 ,  42 ,  88 ,  39 , 153 ,  57 , 276 ,  87 , 465 ,  74 , 984 ,  43 ],
    [ 10 ,  12 ,  30 ,  21 ,  77 ,  26 , 119 ,  19 , 184 ,  29 , 279 ,  37 , 410 ,  43 ],
    [ 6  ,  6  ,  15 ,  14 ,  33 ,  26 ,  68 ,  19 ,  92 ,  29 , 217 ,  37 , 574 ,  43 ],
    [ 6  ,  9  ,  20 ,  14 ,  55 ,  26 , 102 ,  19 , 138 ,  29 , 403 ,  37 , 697 ,  43 ],
    [ 8  ,  12 ,  25 ,  21 ,  66 ,  26 , 102 ,  38 , 138 ,  29 , 217 ,  37 , 697 ,  43 ],
    [ 6  ,  6  ,  15 ,  14 ,  55 ,  26 , 136 ,  19 , 230 ,  29 , 372 ,  37 , 615 ,  43 ],
    [ 12 ,  15 ,  30 ,  28 ,  66 ,  26 , 119 ,  38 , 161 ,  58 , 248 ,  74 , 328 ,  86 ],
    [ 10 ,  12 ,  25 ,  28 ,  66 ,  52 , 153 ,  57 , 391 ,  87 , 589 ,  74 , 820 ,  86 ],
    [ 8  ,  9  ,  20 ,  14 ,  88 ,  26 , 136 ,  19 , 230 ,  29 , 310 ,  37 , 656 ,  43 ],
    [ 8  ,  9  ,  20 ,  21 ,  55 ,  39 ,  85 ,  38 , 138 ,  58 , 217 ,  74 , 287 ,  43 ],
    [ 6  ,  9  ,  15 ,  21 ,  44 ,  26 ,  68 ,  38 ,  92 ,  58 , 217 ,  37 , 369 ,  43 ],
    [ 16 ,  21 ,  40 ,  42 , 110 ,  65 , 187 ,  76 , 276 ,  87 , 434 ,  74 , 1025,  43 ],
    [ 8  ,  9  ,  25 ,  21 ,  66 ,  26 , 119 ,  38 , 184 ,  58 , 248 ,  37 , 1271,  43 ],
    [ 8  ,  12 ,  30 ,  28 ,  88 ,  39 , 153 ,  38 , 253 ,  58 , 465 ,  74 , 615 ,  43 ],
    [ 14 ,  18 ,  45 ,  28 , 121 ,  26 , 187 ,  38 , 299 ,  29 , 527 ,  37 , 738 ,  43 ],
    [ 6  ,  9  ,  15 ,  14 ,  77 ,  26 , 119 ,  38 , 184 ,  29 , 372 ,  37 , 533 ,  43 ],
    [ 6  ,  6  ,  20 ,  14 ,  66 ,  26 , 119 ,  19 , 161 ,  29 , 310 ,  37 , 738 ,  43 ],
    [ 8  ,  6  ,  20 ,  14 ,  44 ,  26 , 102 ,  19 , 184 ,  29 , 372 ,  37 , 533 ,  43 ],
    [ 8  ,  9  ,  30 ,  14 ,  77 ,  26 , 119 ,  19 , 161 ,  29 , 279 ,  37 , 410 ,  43 ]
]

secret_too.json

[
    [ 12  ,  15  ,  30  ,  35  ,  66  ,  26  ,  136 ,  19  ,  184 ,  29  ,  372 ,  37  ,  861 ,  43  , 1363 ],
    [ 22  ,  30  ,  55  ,  63  ,  143 ,  78  ,  323 ,  76  ,  460 ,  87  ,  899 ,  74  , 2214 ,  86  , 6157 ],
    [ 14  ,  15  ,  45  ,  28  ,  110 ,  52  ,  221 ,  57  ,  345 ,  87  ,  558 ,  111 , 1107 ,  86  , 1316 ],
    [ 104 ,  144 ,  290 ,  259 ,  902 ,  468 , 1428 ,  589 , 2070 ,  696 , 2883 ,  370 , 8118 ,  86  , 24581],
    [ 120 ,  180 ,  395 ,  364 ,  979 ,  429 , 2244 ,  513 , 4853 ,  754 , 6665 ,  740 , 10537,  387 , 14946],
    [ 152 ,  201 ,  385 ,  455 , 1155 ,  624 , 1853 ,  760 , 2714 ,  841 , 5611 , 1036 , 11152,  473 , 13771],
    [ 152 ,  204 ,  430 ,  434 ,  979 ,  806 , 1615 , 1102 , 3082 , 1653 , 4433 ,  925 , 6724 ,  774 , 9541 ],
    [ 134 ,  162 ,  340 ,  350 ,  902 ,  520 , 1496 ,  741 , 2047 , 1131 , 6510 ,  851 , 15826,  215 , 18283],
    [ 148 ,  138 ,  375 ,  308 , 1144 ,  468 , 2193 ,  399 , 3473 ,  435 , 4774 ,  370 , 10455,  129 , 20633],
    [ 28  ,  21  ,  75  ,  49  ,  187 ,  91  ,  289 ,  76  ,  506 ,  87  ,  837 ,  74  , 1517 ,  86  , 3525 ],
    [ 332 ,  462 ,  850 ,  861 , 1991 , 1417 , 3264 , 1558 , 4968 , 1885 , 8246 , 2072 , 15621,  86  , 22137],
    [ 168 ,  156 ,  450 ,  308 , 1254 ,  416 , 1989 ,  513 , 3657 ,  580 , 5239 ,  666 , 9102 ,  645 , 18142],
    [ 144 ,  204 ,  380 ,  392 ,  946 ,  312 , 1649 ,  304 , 2783 ,  232 , 4154 ,  259 , 11972,  43  , 19646],
    [ 16  ,  18  ,  45  ,  35  ,  110 ,  39  ,  238 ,  38  ,  345 ,  58  ,  651 ,  74  ,  902 ,  43  , 1175 ],
    [ 222 ,  303 ,  620 ,  686 , 1507 ,  871 , 2499 ,  950 , 5497 , 1189 , 7874 , 1221 , 14268,  43  , 43099]
]

code_check.py

(not actually part of the project but whatever)

import re
import importlib
import inspect
from pathlib import Path
from types import FunctionType
from typing import Any


files = [
    "advanced",
    "analyze_tic_tac_toe_states",
    "animation",
    "basics",
    "basic_data",
    "blend_modes",
    "code_check",
    "color",
    "control",
    "gamecontrol",
    "logic",
    "main",
    "preview",
    "shared",
    "theme",
]

modules = {}

FOLDER = str(Path(__file__).parent).replace("\\", "/")
FUNCTIONS = {}


def analyze_function(func: FunctionType) -> None:
    annotations = func.__annotations__
    code = func.__code__
    FUNCTIONS[func.__qualname__] = {
        "annotated": bool(annotations),
        "annotations": {
            k: (s[8:-2] if (s := str(v))[0] == "<" else s)
            for k, v in annotations.items()
        },
        "return_annotated": "return" in annotations,
        "unannotated_vars": [
            name
            for name in code.co_varnames[: code.co_argcount]
            if name != "self" and name not in annotations
        ],
        "false_void": annotations.get("return", False) is None
        and re.match("\\breturn\\b", inspect.getsource(func)),
    }


def is_custom_function(
    obj: Any, kind: type[FunctionType] | type[staticmethod], module: str = "preview"
) -> bool:
    return isinstance(obj, kind) and obj.__module__ == module


s = 0
for file in files:
    print(file)
    script = Path(f"{FOLDER}/{file}.py").read_text()
    l = len(script)
    s += l
    print(f"Character Count: {l}")
    print(f"Nonsspace count: {sum(not c.isspace() for c in script)}")
    lines = script.splitlines()
    print(f"Line count: {len(lines)}")
    class_count = 0
    if file != "code_check":
        for obj in importlib.import_module(file).__dict__.values():
            if inspect.isclass(obj) and obj.__module__ == file:
                class_count += 1
                for key, attr in obj.__dict__.items():
                    if is_custom_function(attr, FunctionType, file):
                        analyze_function(attr)
                    elif is_custom_function(attr, staticmethod, file):
                        analyze_function(getattr(obj, key))

            elif is_custom_function(obj, FunctionType):
                analyze_function(obj)

    print(f"class count: {class_count}")
    print(f"function count: {len(FUNCTIONS)}")
    print(f"Non-blank line count: {sum(bool(line) for line in lines)}")
    print(f"Code characters count: {sum(len(line.rstrip()) for line in lines)}")
    print()
    print("functions with type hint errors")
    errors = 0
    for key, val in FUNCTIONS.items():
        if (
            val["unannotated_vars"]
            or not val["annotated"]
            or not val["return_annotated"]
            or val["false_void"]
        ):
            errors += 1
            print(key)

    print(f"No functions with type hint errors: {not errors}")
    FUNCTIONS.clear()
    print()
    print()

print(s)

Now I have posted all the files you will need to run the project, the only things that are missing are the icons, you can find them at the GitHub repository I linked above.

How can I improve my code? Is the code structure well organized? Is my code Pythonic? I plan to implement the same project in C++ to improve my C++ skills vastly, after feedbacks here are received.

What are the improvements?


Made a tiny fix, I just realized that the regex for finding 'return' strings as a single word contains a bug. Fixed that.


Update

It has been two days since the questions were posted, and I haven't received a response yet, so it is time to bump them.

I have made a few changes. First I have fixed a small bug.

Then I made some redesigns of the UI, as you can see, inside playerboxes the widgets are now positioned in the center, and I have made the score labels wide enough so that they can show 6 digit numbers.

And obviously, I have added the ability to customize the title bar. I have also made every window completely unresizable, and I added emojis to the game over messages.

And last but not least, I have made the random styles half-decent, I have generated hue, saturation and lightness values separately, the hues have a minimum value of 180 and a maximum value of 330 and they are biased to 249.33, if the target is a background color, else it can be any hue.

The saturation values have a lower bound of 0.5 and upper bound of 1 (1 being full saturation), and they are biased towards 0.7284.

And HSV values of the colors are determined by the role of the color keys, each role has a lower bound and an upper bound and target value.

And then the HSV color is converted to RGB color, this ensures the generated styles don't look downright awful to me, but I don't know others' preferences.

And now the program cannot randomly choose "dashed", "dotted", "hidden", "none" border styles, and the 6 remaining border styles have different chances to be chosen as well.


Update

I have fixed yet another small bug that causes the game counter not to update when the game ends in a draw state.

\$\endgroup\$
1

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Browse other questions tagged or ask your own question.