Skip to content

API Reference

init.py: notty_game package.

main

Main entrypoint for the project.

get_screen()

Create the game window.

Parameters:

Name Type Description Default
app_width

Width of the window.

required
app_height

Height of the window.

required
Source code in notty/main.py
75
76
77
78
79
80
81
82
83
84
85
def get_screen() -> pygame.Surface:
    """Create the game window.

    Args:
        app_width: Width of the window.
        app_height: Height of the window.
    """
    screen = pygame.display.set_mode((APP_WIDTH, APP_HEIGHT))
    # set the title
    pygame.display.set_caption(APP_NAME)
    return screen

main()

Start the notty game.

Source code in notty/main.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def main() -> None:
    """Start the notty game."""
    # Configure logging
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )

    pygame.init()
    start_background_music()

    try:
        # Main loop - allows restarting the game
        while True:
            result = run()

            # If user chose to quit, exit
            if result == "quit":
                break
            # If result is "new_game", loop continues and starts fresh

    finally:
        # Save Q-Learning agent before exiting
        save_qlearning_agent()
        pygame.quit()

run()

Run the game.

Returns:

Type Description
str

"new_game" if user wants to restart, "quit" if user wants to exit.

Source code in notty/main.py
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def run() -> str:
    """Run the game.

    Returns:
        "new_game" if user wants to restart, "quit" if user wants to exit.
    """
    # Clear active players list to prevent positioning issues on restart
    VisualPlayer.ACTIVE_PLAYERS.clear()

    screen = get_screen()
    players = get_players(screen)
    game = VisualGame(screen, players)

    # Run the event loop and return the result
    return run_event_loop(game)

run_event_loop(game)

Run the main event loop.

Parameters:

Name Type Description Default
game VisualGame

The game instance.

required

Returns:

Type Description
str

"new_game" if user wants to start a new game, "quit" if user wants to quit.

Source code in notty/main.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def run_event_loop(game: VisualGame) -> str:
    """Run the main event loop.

    Args:
        game: The game instance.

    Returns:
        "new_game" if user wants to start a new game, "quit" if user wants to quit.
    """
    clock = pygame.time.Clock()

    while True:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return "quit"
            if game.all_players_have_no_cards():
                VisualGame.distribute_starting_cards(game)
                continue
            if event.type == pygame.MOUSEBUTTONDOWN:
                mouse_x, mouse_y = pygame.mouse.get_pos()
                game.action_board.handle_click(mouse_x, mouse_y)
                continue

        computer_chooses_action(game)

        game.draw()

        # Check for winner
        if game.check_win_condition():
            # Draw one final time to show the winning state
            game.draw()
            pygame.display.flip()

            # Show winner and get user choice
            return show_winner(game)

        # Update display
        pygame.display.flip()
        clock.tick(60)  # 60 FPS

show_winner(game)

Show the winner display and get user choice.

Parameters:

Name Type Description Default
game VisualGame

The game instance.

required

Returns:

Type Description
str

"new_game" if user wants to start a new game, "quit" if user wants to quit.

Source code in notty/main.py
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def show_winner(game: VisualGame) -> str:
    """Show the winner display and get user choice.

    Args:
        game: The game instance.

    Returns:
        "new_game" if user wants to start a new game, "quit" if user wants to quit.
    """
    if game.winner is None:
        return "quit"

    # Show winner display
    winner_display = WinnerDisplay(game.screen, game.winner)
    return winner_display.show()

start_background_music()

Start looping background music if possible.

Source code in notty/main.py
48
49
50
51
52
53
54
55
def start_background_music() -> None:
    """Start looping background music if possible."""
    music_path = resource_path("music.mp3", music)

    pygame.mixer.init()
    pygame.mixer.music.load(str(music_path))
    pygame.mixer.music.set_volume(0.4)
    pygame.mixer.music.play(-1)  # # loop forever

rig

init module.

builders

init module.

builder

Build script.

All subclasses of Builder in the builds package are automatically called.

NottyBuilder

Bases: PyInstallerBuilder

Builder for notty.

Source code in notty/rig/builders/builder.py
13
14
15
16
17
18
class NottyBuilder(PyInstallerBuilder):
    """Builder for notty."""

    def entry_point_module(self) -> ModuleType:
        """Get the entry point module."""
        return main
entry_point_module()

Get the entry point module.

Source code in notty/rig/builders/builder.py
16
17
18
def entry_point_module(self) -> ModuleType:
    """Get the entry point module."""
    return main

cli

init module.

shared_subcommands

Shared commands for the CLI.

This module provides shared CLI commands that can be used by multiple packages in a multi-package architecture. These commands are automatically discovered and added to the CLI by pyrig. Example is version command that is available in all packages. uv run my-awesome-project version will return my-awesome-project version 0.1.0

version()

Print the version of notty.

Source code in notty/rig/cli/shared_subcommands.py
13
14
15
def version() -> None:
    """Print the version of notty."""
    typer.echo("Is overriding pyrigs default version command")

subcommands

Project-specific CLI commands.

Add custom CLI commands here as public functions. All public functions are automatically discovered and registered as CLI commands.

run()

Run the notty game.

Source code in notty/rig/cli/subcommands.py
 8
 9
10
11
12
def run() -> None:
    """Run the notty game."""
    from notty.main import main  # noqa: PLC0415

    main()

configs

init module.

configs

Configs for pyrig.

All subclasses of ConfigFile in the configs package are automatically called.

BuildWorkflowConfigFile

Bases: WorkflowConfigFileMixin, BuildWorkflowConfigFile

Build workflow.

Extends winipedia_utils build workflow to add additional steps. This is necessary to make pyside6 work on github actions which is a headless linux environment.

Source code in notty/rig/configs/configs.py
81
82
83
84
85
86
87
class BuildWorkflowConfigFile(WorkflowConfigFileMixin, BaseBuildWorkflowConfigFile):
    """Build workflow.

    Extends winipedia_utils build workflow to add additional steps.
    This is necessary to make pyside6 work on github actions which is a headless linux
    environment.
    """
step_pre_install_pygame_from_binary()

Get the step to install PySide6 dependencies.

Source code in notty/rig/configs/configs.py
53
54
55
56
57
58
def step_pre_install_pygame_from_binary(self) -> dict[str, Any]:
    """Get the step to install PySide6 dependencies."""
    return self.step(
        step_func=self.step_pre_install_pygame_from_binary,
        run="uv pip install pygame --only-binary=:all:",
    )
steps_core_installed_setup(*args, **kwargs)

Get the setup steps.

We need to install additional system dependencies for pyside6.

Source code in notty/rig/configs/configs.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def steps_core_installed_setup(
    self,
    *args: Any,
    **kwargs: Any,
) -> list[dict[str, Any]]:
    """Get the setup steps.

    We need to install additional system dependencies for pyside6.
    """
    steps = super().steps_core_installed_setup(
        *args,
        **kwargs,
    )

    index = next(
        i
        for i, step in enumerate(steps)
        if step["id"] == self.make_id_from_func(self.step_install_dependencies)
    )
    steps.insert(index + 1, self.step_pre_install_pygame_from_binary())
    return steps
HealthCheckWorkflowConfigFile

Bases: WorkflowConfigFileMixin, HealthCheckWorkflowConfigFile

Health check workflow.

Extends winipedia_utils health check workflow to add additional steps. This is necessary to make pyside6 work on github actions which is a headless linux environment.

Source code in notty/rig/configs/configs.py
61
62
63
64
65
66
67
68
69
class HealthCheckWorkflowConfigFile(
    WorkflowConfigFileMixin, BaseHealthCheckWorkflowConfigFile
):
    """Health check workflow.

    Extends winipedia_utils health check workflow to add additional steps.
    This is necessary to make pyside6 work on github actions which is a headless linux
    environment.
    """
step_pre_install_pygame_from_binary()

Get the step to install PySide6 dependencies.

Source code in notty/rig/configs/configs.py
53
54
55
56
57
58
def step_pre_install_pygame_from_binary(self) -> dict[str, Any]:
    """Get the step to install PySide6 dependencies."""
    return self.step(
        step_func=self.step_pre_install_pygame_from_binary,
        run="uv pip install pygame --only-binary=:all:",
    )
steps_core_installed_setup(*args, **kwargs)

Get the setup steps.

We need to install additional system dependencies for pyside6.

Source code in notty/rig/configs/configs.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def steps_core_installed_setup(
    self,
    *args: Any,
    **kwargs: Any,
) -> list[dict[str, Any]]:
    """Get the setup steps.

    We need to install additional system dependencies for pyside6.
    """
    steps = super().steps_core_installed_setup(
        *args,
        **kwargs,
    )

    index = next(
        i
        for i, step in enumerate(steps)
        if step["id"] == self.make_id_from_func(self.step_install_dependencies)
    )
    steps.insert(index + 1, self.step_pre_install_pygame_from_binary())
    return steps
PyprojectConfigFile

Bases: PyprojectConfigFile

Pyproject config file.

Extends winipedia_utils pyproject config file to add additional config.

Source code in notty/rig/configs/configs.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
class PyprojectConfigFile(BasePyprojectConfigFile):
    """Pyproject config file.

    Extends winipedia_utils pyproject config file to add additional config.
    """

    def _configs(self) -> dict[str, Any]:
        """Get the configs."""
        configs = super()._configs()

        # not testing, so adjust pytest addopts
        addopts = configs["tool"]["pytest"]["ini_options"]["addopts"]
        # use regex to replace --cov-fail-under=SOME_NUMBER with --cov-fail-under=0
        configs["tool"]["pytest"]["ini_options"]["addopts"] = re.sub(
            r"--cov-fail-under=\d+", "--cov-fail-under=0", addopts
        )

        # add mypy settings
        configs["tool"]["mypy"] = {
            "strict": True,
            "warn_unreachable": True,
            "show_error_codes": True,
            "files": ".",
        }
        return configs

    def dependencies(self) -> list[str]:
        """Get the dependencies."""
        deps = super().dependencies()
        # add pygame
        return sorted([*["pygame", "platformdirs"], *deps])
_configs()

Get the configs.

Source code in notty/rig/configs/configs.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
def _configs(self) -> dict[str, Any]:
    """Get the configs."""
    configs = super()._configs()

    # not testing, so adjust pytest addopts
    addopts = configs["tool"]["pytest"]["ini_options"]["addopts"]
    # use regex to replace --cov-fail-under=SOME_NUMBER with --cov-fail-under=0
    configs["tool"]["pytest"]["ini_options"]["addopts"] = re.sub(
        r"--cov-fail-under=\d+", "--cov-fail-under=0", addopts
    )

    # add mypy settings
    configs["tool"]["mypy"] = {
        "strict": True,
        "warn_unreachable": True,
        "show_error_codes": True,
        "files": ".",
    }
    return configs
dependencies()

Get the dependencies.

Source code in notty/rig/configs/configs.py
116
117
118
119
120
def dependencies(self) -> list[str]:
    """Get the dependencies."""
    deps = super().dependencies()
    # add pygame
    return sorted([*["pygame", "platformdirs"], *deps])
ReleaseWorkflowConfigFile

Bases: WorkflowConfigFileMixin, ReleaseWorkflowConfigFile

Release workflow.

Extends winipedia_utils release workflow to add additional steps. This is necessary to make pyside6 work on github actions which is a headless linux environment.

Source code in notty/rig/configs/configs.py
72
73
74
75
76
77
78
class ReleaseWorkflowConfigFile(WorkflowConfigFileMixin, BaseReleaseWorkflowConfigFile):
    """Release workflow.

    Extends winipedia_utils release workflow to add additional steps.
    This is necessary to make pyside6 work on github actions which is a headless linux
    environment.
    """
step_pre_install_pygame_from_binary()

Get the step to install PySide6 dependencies.

Source code in notty/rig/configs/configs.py
53
54
55
56
57
58
def step_pre_install_pygame_from_binary(self) -> dict[str, Any]:
    """Get the step to install PySide6 dependencies."""
    return self.step(
        step_func=self.step_pre_install_pygame_from_binary,
        run="uv pip install pygame --only-binary=:all:",
    )
steps_core_installed_setup(*args, **kwargs)

Get the setup steps.

We need to install additional system dependencies for pyside6.

Source code in notty/rig/configs/configs.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def steps_core_installed_setup(
    self,
    *args: Any,
    **kwargs: Any,
) -> list[dict[str, Any]]:
    """Get the setup steps.

    We need to install additional system dependencies for pyside6.
    """
    steps = super().steps_core_installed_setup(
        *args,
        **kwargs,
    )

    index = next(
        i
        for i, step in enumerate(steps)
        if step["id"] == self.make_id_from_func(self.step_install_dependencies)
    )
    steps.insert(index + 1, self.step_pre_install_pygame_from_binary())
    return steps
WorkflowConfigFileMixin

Bases: WorkflowConfigFile

Mixin to add PySide6-specific workflow steps.

This mixin provides common overrides for PySide6 workflows to work on GitHub Actions headless Linux environments.

Source code in notty/rig/configs/configs.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class WorkflowConfigFileMixin(BaseWorkflowConfigFile):
    """Mixin to add PySide6-specific workflow steps.

    This mixin provides common overrides for PySide6 workflows to work on
    GitHub Actions headless Linux environments.
    """

    def steps_core_installed_setup(
        self,
        *args: Any,
        **kwargs: Any,
    ) -> list[dict[str, Any]]:
        """Get the setup steps.

        We need to install additional system dependencies for pyside6.
        """
        steps = super().steps_core_installed_setup(
            *args,
            **kwargs,
        )

        index = next(
            i
            for i, step in enumerate(steps)
            if step["id"] == self.make_id_from_func(self.step_install_dependencies)
        )
        steps.insert(index + 1, self.step_pre_install_pygame_from_binary())
        return steps

    def step_pre_install_pygame_from_binary(self) -> dict[str, Any]:
        """Get the step to install PySide6 dependencies."""
        return self.step(
            step_func=self.step_pre_install_pygame_from_binary,
            run="uv pip install pygame --only-binary=:all:",
        )
step_pre_install_pygame_from_binary()

Get the step to install PySide6 dependencies.

Source code in notty/rig/configs/configs.py
53
54
55
56
57
58
def step_pre_install_pygame_from_binary(self) -> dict[str, Any]:
    """Get the step to install PySide6 dependencies."""
    return self.step(
        step_func=self.step_pre_install_pygame_from_binary,
        run="uv pip install pygame --only-binary=:all:",
    )
steps_core_installed_setup(*args, **kwargs)

Get the setup steps.

We need to install additional system dependencies for pyside6.

Source code in notty/rig/configs/configs.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def steps_core_installed_setup(
    self,
    *args: Any,
    **kwargs: Any,
) -> list[dict[str, Any]]:
    """Get the setup steps.

    We need to install additional system dependencies for pyside6.
    """
    steps = super().steps_core_installed_setup(
        *args,
        **kwargs,
    )

    index = next(
        i
        for i, step in enumerate(steps)
        if step["id"] == self.make_id_from_func(self.step_install_dependencies)
    )
    steps.insert(index + 1, self.step_pre_install_pygame_from_binary())
    return steps

resources

init module.

music

init module.

visuals

init module.

cards

init module.

black

init module.

blue

init module.

green

init module.

red

init module.

yellow

init module.

deck

init module.

hand

init module.

players

init module.

tests

init module.

fixtures

init module.

fixtures

Defines pytest fixtures.

assert_override_works(assert_package_manager_is_up_to_date)

Checks that the previous fixture override worked as expected.

Source code in notty/rig/tests/fixtures/fixtures.py
16
17
18
19
20
21
22
@pytest.fixture(scope="session", autouse=True)
def assert_override_works(assert_package_manager_is_up_to_date: None) -> None:
    """Checks that the previous fixture override worked as expected."""
    assert assert_package_manager_is_up_to_date is None, (
        "Previous fixture override did not run as expected"
    )
    assert override_works, "Previous fixture override did not work as expected"
assert_package_manager_is_up_to_date()

Overrides pyrigs fixture of the same name to check overriding works correctly.

Source code in notty/rig/tests/fixtures/fixtures.py
 8
 9
10
11
12
13
@pytest.fixture(scope="session")
def assert_package_manager_is_up_to_date() -> None:
    """Overrides pyrigs fixture of the same name to check overriding works correctly."""
    global override_works  # noqa: PLW0603
    override_works = True
    assert override_works, "Fixture override did not work as expected"

mirror_test

Subclassing MirrorTest to test the notty game.

MirrorTestConfigFile

Bases: MirrorTestConfigFile

Subclassing MirrorTest to test the notty game.

Source code in notty/rig/tests/mirror_test.py
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MirrorTestConfigFile(BaseMirrorTestConfigFile):
    """Subclassing MirrorTest to test the notty game."""

    def test_func_skeleton(self, test_func_name: str) -> str:
        """Get the test function skeleton."""
        return f'''

def {test_func_name}() -> None:
    """Test function."""
'''

    def test_method_skeleton(self, test_method_name: str) -> str:
        """Get the test method skeleton."""
        return f'''
    def {test_method_name}(self) -> None:
        """Test method."""
'''
test_func_skeleton(test_func_name)

Get the test function skeleton.

Source code in notty/rig/tests/mirror_test.py
 9
10
11
12
13
14
15
    def test_func_skeleton(self, test_func_name: str) -> str:
        """Get the test function skeleton."""
        return f'''

def {test_func_name}() -> None:
    """Test function."""
'''
test_method_skeleton(test_method_name)

Get the test method skeleton.

Source code in notty/rig/tests/mirror_test.py
17
18
19
20
21
22
    def test_method_skeleton(self, test_method_name: str) -> str:
        """Get the test method skeleton."""
        return f'''
    def {test_method_name}(self) -> None:
        """Test method."""
'''

tools

Tool wrappers for CLI tools used in development workflows.

Tools are subclasses of Tool providing methods that return Args objects for type-safe command construction and execution.

type_checker

Overriding the type checker to get from ty to mypy.

MypyTypeChecker

Bases: TypeChecker

Mypy type checker.

Source code in notty/rig/tools/type_checker.py
 7
 8
 9
10
11
12
13
14
15
16
class MypyTypeChecker(TypeChecker):
    """Mypy type checker."""

    def name(self) -> str:
        """Get the name of the type checker."""
        return "mypy"

    def check_args(self, *args: str) -> Args:
        """Get the args for checking types."""
        return self.args(*args)
check_args(*args)

Get the args for checking types.

Source code in notty/rig/tools/type_checker.py
14
15
16
def check_args(self, *args: str) -> Args:
    """Get the args for checking types."""
    return self.args(*args)
name()

Get the name of the type checker.

Source code in notty/rig/tools/type_checker.py
10
11
12
def name(self) -> str:
    """Get the name of the type checker."""
    return "mypy"

src

src package.

computer_action_selection

Computer action selection.

choose_card_to_discard(game)

Choose which card to discard in draw-discard-discard action.

Parameters:

Name Type Description Default
game VisualGame

The game instance.

required

Returns:

Type Description
VisualCard

The card to discard.

Source code in notty/src/computer_action_selection.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def choose_card_to_discard(game: "VisualGame") -> "VisualCard":
    """Choose which card to discard in draw-discard-discard action.

    Args:
        game: The game instance.

    Returns:
        The card to discard.
    """
    current_player = game.get_current_player()
    cards = current_player.hand.cards

    if not cards:
        msg = "No cards to discard"
        raise ValueError(msg)

    # Strategy: discard cards that are least likely to form groups
    # Count how many cards of each color and number we have

    color_counts = Counter(card.color for card in cards)
    number_counts = Counter(card.number for card in cards)

    # Score each card (lower is worse, more likely to discard)
    card_scores: list[tuple[VisualCard, int]] = []
    for card in cards:
        score = 0
        # Cards with more of the same color are better (can form sequences)
        score += color_counts[card.color] * 2
        # Cards with more of the same number are better (can form sets)
        score += number_counts[card.number] * 2

        # Check if card can be part of a sequence
        for other_card in cards:
            if (
                other_card.color == card.color
                and abs(other_card.number - card.number) == 1
            ):
                score += 3

        card_scores.append((card, score))

    # Sort by score and pick one of the worst cards
    card_scores.sort(key=lambda x: x[1])
    # Pick the worst card
    return card_scores[0][0]

choose_draw_count(game)

Choose how many cards to draw (1-3) using strategic analysis.

Strategy: - Draw fewer cards if hand is nearly full (approaching 20 card limit) - Draw more cards if we have potential to form groups - Draw fewer cards if opponents are close to winning - Consider deck size to avoid running out

Parameters:

Name Type Description Default
game VisualGame

The game instance.

required

Returns:

Type Description
int

Number of cards to draw (1-3).

Source code in notty/src/computer_action_selection.py
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def choose_draw_count(game: "VisualGame") -> int:  # noqa: C901
    """Choose how many cards to draw (1-3) using strategic analysis.

    Strategy:
    - Draw fewer cards if hand is nearly full (approaching 20 card limit)
    - Draw more cards if we have potential to form groups
    - Draw fewer cards if opponents are close to winning
    - Consider deck size to avoid running out

    Args:
        game: The game instance.

    Returns:
        Number of cards to draw (1-3).
    """
    current_player = game.get_current_player()
    hand_size = current_player.hand.size()
    deck_size = game.deck.size()
    cards = current_player.hand.cards

    # Base decision: start with 2 cards (balanced approach)
    draw_count = 2

    # Factor 1: Hand size constraint (max 20 cards)
    # If we're close to the limit, draw fewer cards
    if hand_size >= 18:  # noqa: PLR2004
        draw_count = 1  # Very close to limit
    elif hand_size >= 15:  # noqa: PLR2004
        draw_count = min(draw_count, 2)  # Moderately close
    elif hand_size <= 6:  # noqa: PLR2004
        draw_count = 3  # Very few cards, draw more aggressively

    # Factor 2: Analyze hand potential for forming groups
    # Count cards by color and number to see if we're close to groups
    color_counts = Counter(card.color for card in cards)
    number_counts = Counter(card.number for card in cards)

    # Check if we're close to forming a sequence (3+ consecutive same color)
    sequence_potential = 0
    for color in color_counts:
        color_cards = sorted([c.number for c in cards if c.color == color])
        # Check for consecutive numbers
        for i in range(len(color_cards) - 1):
            if color_cards[i + 1] - color_cards[i] == 1:
                sequence_potential += 1

    # Check if we're close to forming a set (4+ same number different colors)
    set_potential = sum(1 for count in number_counts.values() if count >= 3)  # noqa: PLR2004

    # If we have high potential for groups, draw more to complete them
    total_potential = sequence_potential + set_potential
    if total_potential >= 3:  # noqa: PLR2004
        draw_count = min(draw_count + 1, 3)
    elif total_potential == 0 and hand_size > 10:  # noqa: PLR2004
        # No potential and many cards - be conservative
        draw_count = max(draw_count - 1, 1)

    # Factor 3: Opponent analysis
    # If any opponent has very few cards, we need to be aggressive
    other_players = game.get_other_players()
    min_opponent_hand = (
        min(p.hand.size() for p in other_players) if other_players else 20
    )

    if min_opponent_hand <= 4:  # noqa: PLR2004
        # Opponent is close to winning, be more aggressive
        draw_count = min(draw_count + 1, 3)

    # Factor 4: Deck size consideration
    # If deck is running low, be more conservative
    if deck_size < 10:  # noqa: PLR2004
        draw_count = min(draw_count, 1)
    elif deck_size < 20:  # noqa: PLR2004
        draw_count = min(draw_count, 2)

    # Final constraint: ensure we don't exceed hand limit
    max_drawable = min(MAX_HAND_SIZE - hand_size, deck_size)
    draw_count = min(draw_count, max_drawable, 3)
    return max(draw_count, 1)  # Always draw at least 1

choose_target_player(game)

Choose which player to steal from.

Parameters:

Name Type Description Default
game VisualGame

The game instance.

required

Returns:

Type Description
VisualPlayer

The target player to steal from.

Source code in notty/src/computer_action_selection.py
123
124
125
126
127
128
129
130
131
132
133
134
135
def choose_target_player(game: "VisualGame") -> "VisualPlayer":
    """Choose which player to steal from.

    Args:
        game: The game instance.

    Returns:
        The target player to steal from.
    """
    other_players = game.get_other_players()

    # Prefer stealing from player with most cards
    return max(other_players, key=lambda p: p.hand.size())

computer_chooses_action(game)

Computer chooses an action using Q-Learning.

Parameters:

Name Type Description Default
game VisualGame

The game instance.

required
Source code in notty/src/computer_action_selection.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
def computer_chooses_action(game: "VisualGame") -> None:
    """Computer chooses an action using Q-Learning.

    Args:
        game: The game instance.
    """
    # Check if current player is a computer player and auto-pass
    current_player = game.get_current_player()
    if current_player.is_human or not game.can_computer_act():
        return
    game.mark_computer_action()

    # Get the Q-Learning agent
    agent = get_qlearning_agent()

    # Store previous hand size to calculate reward
    prev_hand_size = current_player.hand.size()

    # Choose action using Q-Learning
    action = agent.choose_action(game)

    # Choose appropriate parameters based on the action
    count, card, cards, target_player = None, None, None, None

    if action == Action.DRAW_MULTIPLE:
        count = choose_draw_count(game)
    elif action == Action.STEAL:
        target_player = choose_target_player(game)
    elif action == Action.DRAW_DISCARD_DISCARD:
        card = choose_card_to_discard(game)
    elif action == Action.DISCARD_GROUP:
        cards = find_best_discard_group(game)
        if cards is None:
            # Fallback: if we can't find a valid group, pass instead
            action = Action.NEXT_TURN

    # Execute the action
    game.do_action(
        action, count=count, card=card, cards=cards, target_player=target_player
    )

    # Calculate reward based on the action taken
    player_index = game.players.index(current_player)
    reward = game.calculate_reward(player_index, action)

    # Additional reward shaping based on hand size change
    new_hand_size = current_player.hand.size()
    hand_size_change = prev_hand_size - new_hand_size
    if hand_size_change > 0:
        # Reward for reducing hand size
        reward += hand_size_change * 2.0

    # Learn from the action
    agent.learn(game, reward)

    # Auto-save Q-table periodically (every 100 actions)
    if agent.total_actions % 100 == 0:
        save_path = str(get_qlearning_save_path())
        agent.save(save_path)

find_best_discard_group(game)

Find the best group of cards to discard.

Parameters:

Name Type Description Default
game VisualGame

The game instance.

required

Returns:

Type Description
list[VisualCard] | None

List of cards to discard, or None if no valid group found.

Source code in notty/src/computer_action_selection.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
def find_best_discard_group(game: "VisualGame") -> list["VisualCard"] | None:
    """Find the best group of cards to discard.

    Args:
        game: The game instance.

    Returns:
        List of cards to discard, or None if no valid group found.
    """
    discardable_groups = game.get_discardable_groups()
    if not discardable_groups:
        return None
    # Prefer larger groups (discard more cards)
    discardable_groups.sort(key=len, reverse=True)
    return discardable_groups[0]

get_qlearning_agent()

Get or create the Q-Learning agent.

Returns:

Type Description
QLearningAgent

The Q-Learning agent instance.

Source code in notty/src/computer_action_selection.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
def get_qlearning_agent() -> QLearningAgent:
    """Get or create the Q-Learning agent.

    Returns:
        The Q-Learning agent instance.
    """
    agent = _qlearning_agent_container["agent"]
    if agent is None:
        agent = QLearningAgent(
            alpha=0.1,  # Learning rate
            gamma=0.9,  # Discount factor
            epsilon=0.2,  # Initial exploration rate
            epsilon_decay=0.9995,  # Decay rate
            epsilon_min=0.05,  # Minimum exploration
        )
        _qlearning_agent_container["agent"] = agent
        # Try to load existing Q-table
        save_path = str(get_qlearning_save_path())
        agent.load(save_path)
    return agent

reset_qlearning_episode()

Reset the Q-Learning agent for a new episode/game.

Source code in notty/src/computer_action_selection.py
270
271
272
273
def reset_qlearning_episode() -> None:
    """Reset the Q-Learning agent for a new episode/game."""
    if _qlearning_agent_container["agent"] is not None:
        _qlearning_agent_container["agent"].reset_episode()

save_qlearning_agent()

Save the Q-Learning agent's Q-table.

Source code in notty/src/computer_action_selection.py
263
264
265
266
267
def save_qlearning_agent() -> None:
    """Save the Q-Learning agent's Q-table."""
    if _qlearning_agent_container["agent"] is not None:
        save_path = str(get_qlearning_save_path())
        _qlearning_agent_container["agent"].save(save_path)

consts

Constants for the game.

player_selection

Player selection screen and player setup.

get_players(screen)

Get the players with a selection screen to choose who you are.

Source code in notty/src/player_selection.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def get_players(screen: pygame.Surface) -> list[VisualPlayer]:
    """Get the players with a selection screen to choose who you are."""
    # Show selection screen to choose yourself
    selected_player = show_player_selection_screen(screen)

    # Show opponent selection screen
    opponent_names = show_opponent_selection_screen(screen, selected_player)

    # Create all players
    players_list: list[VisualPlayer] = []

    # Add human player
    players_list.append(VisualPlayer(selected_player, is_human=True, screen=screen))

    players_list.extend(
        VisualPlayer(name, is_human=False, screen=screen) for name in opponent_names
    )

    return players_list

show_opponent_selection_screen(screen, human_player)

Show a screen to select 1-2 opponents.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required
human_player str

The name of the human player to exclude.

required

Returns:

Type Description
list[str]

List of selected opponent names.

Source code in notty/src/player_selection.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
def show_opponent_selection_screen(
    screen: pygame.Surface, human_player: str
) -> list[str]:
    """Show a screen to select 1-2 opponents.

    Args:
        screen: The pygame display surface.
        human_player: The name of the human player to exclude.

    Returns:
        List of selected opponent names.
    """
    all_names = VisualPlayer.get_all_player_names()
    available_names = [name for name in all_names if name != human_player]

    selector = PlayerNameSelector(
        screen=screen,
        available_names=available_names,
        title="Choose Opponents (1-2)",
        max_selections=2,
        min_selections=1,
    )
    result = selector.show()
    # Return list of strings
    return result if isinstance(result, list) else [result]

show_player_selection_screen(screen)

Show a screen to select which player you want to be.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required

Returns:

Type Description
str

The name of the selected player.

Source code in notty/src/player_selection.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def show_player_selection_screen(screen: pygame.Surface) -> str:
    """Show a screen to select which player you want to be.

    Args:
        screen: The pygame display surface.

    Returns:
        The name of the selected player.
    """
    all_names = VisualPlayer.get_all_player_names()
    selector = PlayerNameSelector(
        screen=screen,
        available_names=all_names,
        title="Choose Your Player",
        max_selections=1,
        min_selections=1,
    )
    result = selector.show()
    # Return single string (not list)
    return result if isinstance(result, str) else result[0]

qlearning_agent

Q-Learning agent for Notty card game.

QLearningAgent

Q-Learning agent that learns to play Notty through reinforcement learning.

Source code in notty/src/qlearning_agent.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
class QLearningAgent:
    """Q-Learning agent that learns to play Notty through reinforcement learning."""

    def __init__(
        self,
        alpha: float = 0.1,
        gamma: float = 0.9,
        epsilon: float = 0.2,
        epsilon_decay: float = 0.9995,
        epsilon_min: float = 0.05,
    ) -> None:
        """Initialize Q-Learning agent.

        Args:
            alpha: Learning rate (how much to update Q-values).
            gamma: Discount factor (how much to value future rewards).
            epsilon: Exploration rate (probability of random action).
            epsilon_decay: Rate at which epsilon decreases.
            epsilon_min: Minimum epsilon value.
        """
        self.q_table: dict[tuple[int, int, bool, int], dict[str, float]] = defaultdict(
            lambda: defaultdict(float)
        )
        self.alpha = alpha
        self.gamma = gamma
        self.epsilon = epsilon
        self.epsilon_decay = epsilon_decay
        self.epsilon_min = epsilon_min

        # Track learning statistics
        self.total_actions = 0
        self.exploration_actions = 0
        self.last_state: tuple[int, int, bool, int] | None = None
        self.last_action: str | None = None

    def get_state(self, game: "VisualGame") -> tuple[int, int, bool, int]:
        """Extract state features from the game.

        Args:
            game: The game instance.

        Returns:
            Tuple representing the current state.
        """
        current_player = game.get_current_player()
        hand_size = current_player.hand.size()
        deck_size = game.deck.size()

        # Discretize hand size into buckets
        hand_bucket = min(hand_size // 5, 3)  # 0-4, 5-9, 10-14, 15-20

        # Discretize deck size
        deck_bucket = 0 if deck_size < 30 else (1 if deck_size < 60 else 2)  # noqa: PLR2004

        # Check if can discard
        can_discard = game.can_discard_group()

        # Count other players' cards (simplified)
        other_players = game.get_other_players()
        avg_other_hand_size = (
            sum(p.hand.size() for p in other_players) // len(other_players)
            if other_players
            else 0
        )
        other_hand_bucket = min(avg_other_hand_size // 5, 3)

        return (hand_bucket, deck_bucket, can_discard, other_hand_bucket)

    def choose_action(self, game: "VisualGame") -> str:
        """Choose an action using epsilon-greedy policy.

        Args:
            game: The game instance.

        Returns:
            The chosen action name.
        """
        state = self.get_state(game)
        possible_actions = game.get_all_possible_actions()

        if not possible_actions:
            # Fallback - should not happen
            return "next_turn"

        self.total_actions += 1

        # Epsilon-greedy exploration
        if secrets.randbelow(10000) / 10000 < self.epsilon:
            self.exploration_actions += 1
            action = secrets.choice(possible_actions)
        else:
            # Exploitation: choose best known action
            q_values = {
                action: self.q_table[state][action] for action in possible_actions
            }
            max_q = max(q_values.values())
            # If multiple actions have same Q-value, choose randomly among them
            best_actions = [a for a, q in q_values.items() if q == max_q]
            action = secrets.choice(best_actions)

        # Store for learning
        self.last_state = state
        self.last_action = action

        return action

    def learn(self, game: "VisualGame", reward: float) -> None:
        """Update Q-values based on the reward received.

        Args:
            game: The game instance.
            reward: The reward received for the last action.
        """
        if self.last_state is None or self.last_action is None:
            return

        current_state = self.get_state(game)
        possible_actions = game.get_all_possible_actions()

        # Get max Q-value for next state
        if possible_actions:
            next_max_q = max(
                self.q_table[current_state][action] for action in possible_actions
            )
        else:
            next_max_q = 0.0

        # Q-learning update rule
        old_q = self.q_table[self.last_state][self.last_action]
        new_q = old_q + self.alpha * (reward + self.gamma * next_max_q - old_q)
        self.q_table[self.last_state][self.last_action] = new_q

        # Decay epsilon
        self.epsilon = max(self.epsilon_min, self.epsilon * self.epsilon_decay)

    def reset_episode(self) -> None:
        """Reset episode-specific tracking."""
        self.last_state = None
        self.last_action = None

    def save(self, filepath: str = "notty_qtable.pkl") -> None:
        """Save Q-table to file.

        Args:
            filepath: Path to save the Q-table.
        """
        data: dict[
            str, dict[tuple[int, int, bool, int], dict[str, float]] | float | int
        ] = {
            "q_table": dict(self.q_table),
            "epsilon": self.epsilon,
            "total_actions": self.total_actions,
            "exploration_actions": self.exploration_actions,
        }
        Path(filepath).parent.mkdir(parents=True, exist_ok=True)
        with Path(filepath).open("wb") as f:
            pickle.dump(data, f)
        logger.info("Q-table saved to %s", filepath)

    def load(self, filepath: str = "notty_qtable.pkl") -> bool:
        """Load Q-table from file.

        Args:
            filepath: Path to load the Q-table from.

        Returns:
            True if loaded successfully, False otherwise.
        """
        if not Path(filepath).exists():
            logger.info("No Q-table found at %s, starting fresh", filepath)
            return False

        try:
            with Path(filepath).open("rb") as f:
                data = pickle.load(f)  # nosec: B301  # noqa: S301

            # Convert back to defaultdict
            self.q_table = defaultdict(lambda: defaultdict(float))
            for state, actions in data["q_table"].items():
                for action, q_value in actions.items():
                    self.q_table[state][action] = q_value

            self.epsilon = data.get("epsilon", self.epsilon)
            self.total_actions = data.get("total_actions", 0)
            self.exploration_actions = data.get("exploration_actions", 0)

            logger.info("Q-table loaded from %s", filepath)
            logger.info("  States learned: %d", len(self.q_table))
            logger.info("  Total actions: %d", self.total_actions)
            logger.info("  Exploration rate: %.3f", self.epsilon)
        except Exception:
            logger.exception("Error loading Q-table")
            return False
        else:
            return True

    def get_stats(self) -> dict[str, int | float]:
        """Get learning statistics.

        Returns:
            Dictionary with learning statistics.
        """
        exploration_rate = (
            self.exploration_actions / self.total_actions
            if self.total_actions > 0
            else 0
        )
        return {
            "states_learned": len(self.q_table),
            "total_actions": self.total_actions,
            "exploration_actions": self.exploration_actions,
            "exploration_rate": exploration_rate,
            "current_epsilon": self.epsilon,
        }
__init__(alpha=0.1, gamma=0.9, epsilon=0.2, epsilon_decay=0.9995, epsilon_min=0.05)

Initialize Q-Learning agent.

Parameters:

Name Type Description Default
alpha float

Learning rate (how much to update Q-values).

0.1
gamma float

Discount factor (how much to value future rewards).

0.9
epsilon float

Exploration rate (probability of random action).

0.2
epsilon_decay float

Rate at which epsilon decreases.

0.9995
epsilon_min float

Minimum epsilon value.

0.05
Source code in notty/src/qlearning_agent.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def __init__(
    self,
    alpha: float = 0.1,
    gamma: float = 0.9,
    epsilon: float = 0.2,
    epsilon_decay: float = 0.9995,
    epsilon_min: float = 0.05,
) -> None:
    """Initialize Q-Learning agent.

    Args:
        alpha: Learning rate (how much to update Q-values).
        gamma: Discount factor (how much to value future rewards).
        epsilon: Exploration rate (probability of random action).
        epsilon_decay: Rate at which epsilon decreases.
        epsilon_min: Minimum epsilon value.
    """
    self.q_table: dict[tuple[int, int, bool, int], dict[str, float]] = defaultdict(
        lambda: defaultdict(float)
    )
    self.alpha = alpha
    self.gamma = gamma
    self.epsilon = epsilon
    self.epsilon_decay = epsilon_decay
    self.epsilon_min = epsilon_min

    # Track learning statistics
    self.total_actions = 0
    self.exploration_actions = 0
    self.last_state: tuple[int, int, bool, int] | None = None
    self.last_action: str | None = None
choose_action(game)

Choose an action using epsilon-greedy policy.

Parameters:

Name Type Description Default
game VisualGame

The game instance.

required

Returns:

Type Description
str

The chosen action name.

Source code in notty/src/qlearning_agent.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def choose_action(self, game: "VisualGame") -> str:
    """Choose an action using epsilon-greedy policy.

    Args:
        game: The game instance.

    Returns:
        The chosen action name.
    """
    state = self.get_state(game)
    possible_actions = game.get_all_possible_actions()

    if not possible_actions:
        # Fallback - should not happen
        return "next_turn"

    self.total_actions += 1

    # Epsilon-greedy exploration
    if secrets.randbelow(10000) / 10000 < self.epsilon:
        self.exploration_actions += 1
        action = secrets.choice(possible_actions)
    else:
        # Exploitation: choose best known action
        q_values = {
            action: self.q_table[state][action] for action in possible_actions
        }
        max_q = max(q_values.values())
        # If multiple actions have same Q-value, choose randomly among them
        best_actions = [a for a, q in q_values.items() if q == max_q]
        action = secrets.choice(best_actions)

    # Store for learning
    self.last_state = state
    self.last_action = action

    return action
get_state(game)

Extract state features from the game.

Parameters:

Name Type Description Default
game VisualGame

The game instance.

required

Returns:

Type Description
tuple[int, int, bool, int]

Tuple representing the current state.

Source code in notty/src/qlearning_agent.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def get_state(self, game: "VisualGame") -> tuple[int, int, bool, int]:
    """Extract state features from the game.

    Args:
        game: The game instance.

    Returns:
        Tuple representing the current state.
    """
    current_player = game.get_current_player()
    hand_size = current_player.hand.size()
    deck_size = game.deck.size()

    # Discretize hand size into buckets
    hand_bucket = min(hand_size // 5, 3)  # 0-4, 5-9, 10-14, 15-20

    # Discretize deck size
    deck_bucket = 0 if deck_size < 30 else (1 if deck_size < 60 else 2)  # noqa: PLR2004

    # Check if can discard
    can_discard = game.can_discard_group()

    # Count other players' cards (simplified)
    other_players = game.get_other_players()
    avg_other_hand_size = (
        sum(p.hand.size() for p in other_players) // len(other_players)
        if other_players
        else 0
    )
    other_hand_bucket = min(avg_other_hand_size // 5, 3)

    return (hand_bucket, deck_bucket, can_discard, other_hand_bucket)
get_stats()

Get learning statistics.

Returns:

Type Description
dict[str, int | float]

Dictionary with learning statistics.

Source code in notty/src/qlearning_agent.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
def get_stats(self) -> dict[str, int | float]:
    """Get learning statistics.

    Returns:
        Dictionary with learning statistics.
    """
    exploration_rate = (
        self.exploration_actions / self.total_actions
        if self.total_actions > 0
        else 0
    )
    return {
        "states_learned": len(self.q_table),
        "total_actions": self.total_actions,
        "exploration_actions": self.exploration_actions,
        "exploration_rate": exploration_rate,
        "current_epsilon": self.epsilon,
    }
learn(game, reward)

Update Q-values based on the reward received.

Parameters:

Name Type Description Default
game VisualGame

The game instance.

required
reward float

The reward received for the last action.

required
Source code in notty/src/qlearning_agent.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
def learn(self, game: "VisualGame", reward: float) -> None:
    """Update Q-values based on the reward received.

    Args:
        game: The game instance.
        reward: The reward received for the last action.
    """
    if self.last_state is None or self.last_action is None:
        return

    current_state = self.get_state(game)
    possible_actions = game.get_all_possible_actions()

    # Get max Q-value for next state
    if possible_actions:
        next_max_q = max(
            self.q_table[current_state][action] for action in possible_actions
        )
    else:
        next_max_q = 0.0

    # Q-learning update rule
    old_q = self.q_table[self.last_state][self.last_action]
    new_q = old_q + self.alpha * (reward + self.gamma * next_max_q - old_q)
    self.q_table[self.last_state][self.last_action] = new_q

    # Decay epsilon
    self.epsilon = max(self.epsilon_min, self.epsilon * self.epsilon_decay)
load(filepath='notty_qtable.pkl')

Load Q-table from file.

Parameters:

Name Type Description Default
filepath str

Path to load the Q-table from.

'notty_qtable.pkl'

Returns:

Type Description
bool

True if loaded successfully, False otherwise.

Source code in notty/src/qlearning_agent.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def load(self, filepath: str = "notty_qtable.pkl") -> bool:
    """Load Q-table from file.

    Args:
        filepath: Path to load the Q-table from.

    Returns:
        True if loaded successfully, False otherwise.
    """
    if not Path(filepath).exists():
        logger.info("No Q-table found at %s, starting fresh", filepath)
        return False

    try:
        with Path(filepath).open("rb") as f:
            data = pickle.load(f)  # nosec: B301  # noqa: S301

        # Convert back to defaultdict
        self.q_table = defaultdict(lambda: defaultdict(float))
        for state, actions in data["q_table"].items():
            for action, q_value in actions.items():
                self.q_table[state][action] = q_value

        self.epsilon = data.get("epsilon", self.epsilon)
        self.total_actions = data.get("total_actions", 0)
        self.exploration_actions = data.get("exploration_actions", 0)

        logger.info("Q-table loaded from %s", filepath)
        logger.info("  States learned: %d", len(self.q_table))
        logger.info("  Total actions: %d", self.total_actions)
        logger.info("  Exploration rate: %.3f", self.epsilon)
    except Exception:
        logger.exception("Error loading Q-table")
        return False
    else:
        return True
reset_episode()

Reset episode-specific tracking.

Source code in notty/src/qlearning_agent.py
151
152
153
154
def reset_episode(self) -> None:
    """Reset episode-specific tracking."""
    self.last_state = None
    self.last_action = None
save(filepath='notty_qtable.pkl')

Save Q-table to file.

Parameters:

Name Type Description Default
filepath str

Path to save the Q-table.

'notty_qtable.pkl'
Source code in notty/src/qlearning_agent.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def save(self, filepath: str = "notty_qtable.pkl") -> None:
    """Save Q-table to file.

    Args:
        filepath: Path to save the Q-table.
    """
    data: dict[
        str, dict[tuple[int, int, bool, int], dict[str, float]] | float | int
    ] = {
        "q_table": dict(self.q_table),
        "epsilon": self.epsilon,
        "total_actions": self.total_actions,
        "exploration_actions": self.exploration_actions,
    }
    Path(filepath).parent.mkdir(parents=True, exist_ok=True)
    with Path(filepath).open("wb") as f:
        pickle.dump(data, f)
    logger.info("Q-table saved to %s", filepath)

utils

utils.

get_qlearning_save_path()

Get the path for saving Q-Learning agent data.

Returns:

Type Description
Path

Path to the Q-table save file.

Source code in notty/src/utils.py
52
53
54
55
56
57
58
def get_qlearning_save_path() -> Path:
    """Get the path for saving Q-Learning agent data.

    Returns:
        Path to the Q-table save file.
    """
    return get_user_data_dir() / "notty_qtable.pkl"

get_user_data_dir()

Get the user data directory for saving game data.

This works correctly both in development and when packaged with PyInstaller.

Returns:

Type Description
Path

Path to the user data directory.

Source code in notty/src/utils.py
38
39
40
41
42
43
44
45
46
47
48
49
def get_user_data_dir() -> Path:
    """Get the user data directory for saving game data.

    This works correctly both in development and when packaged with PyInstaller.

    Returns:
        Path to the user data directory.
    """
    # Use platformdirs to get the appropriate user data directory
    data_dir = Path(user_data_dir("Notty", "Notty"))
    data_dir.mkdir(parents=True, exist_ok=True)
    return data_dir

get_window_size()

Get the window size based on screen dimensions.

Returns:

Type Description
tuple[int, int]

Tuple of (width, height) for the window.

Source code in notty/src/utils.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def get_window_size() -> tuple[int, int]:
    """Get the window size based on screen dimensions.

    Returns:
        Tuple of (width, height) for the window.
    """
    # Get the display info to determine screen size
    pygame.display.init()
    display_info = pygame.display.Info()
    # quit pygame display
    pygame.display.quit()
    screen_width = display_info.current_w
    screen_height = display_info.current_h

    # Use 80% of screen width and 70% of screen height
    factor = 0.8
    app_width = int(screen_width * factor)
    app_height = int(screen_height * factor)

    # Set minimum dimensions to ensure usability
    min_width = 800
    min_height = 500

    app_width = max(app_width, min_width)
    app_height = max(app_height, min_height)

    return app_width, app_height

visual

init module.

action_board

Action board for displaying available actions to the human player.

Action

Represents an action in the Notty game.

Source code in notty/src/visual/action_board.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Action:
    """Represents an action in the Notty game."""

    DRAW_MULTIPLE = "draw_multiple"
    STEAL = "steal"
    DRAW_DISCARD_DRAW = "draw_discard_draw"
    DRAW_DISCARD_DISCARD = "draw_discard_discard"
    DISCARD_GROUP = "discard_group"
    NEXT_TURN = "next_turn"
    PLAY_FOR_ME = "play_for_me"

    @classmethod
    def get_all_actions(cls) -> set[str]:
        """Get all actions."""
        return {
            cls.DRAW_MULTIPLE,
            cls.STEAL,
            cls.DRAW_DISCARD_DRAW,
            cls.DRAW_DISCARD_DISCARD,
            cls.DISCARD_GROUP,
            cls.NEXT_TURN,
        }
get_all_actions() classmethod

Get all actions.

Source code in notty/src/visual/action_board.py
30
31
32
33
34
35
36
37
38
39
40
@classmethod
def get_all_actions(cls) -> set[str]:
    """Get all actions."""
    return {
        cls.DRAW_MULTIPLE,
        cls.STEAL,
        cls.DRAW_DISCARD_DRAW,
        cls.DRAW_DISCARD_DISCARD,
        cls.DISCARD_GROUP,
        cls.NEXT_TURN,
    }
ActionBoard

Action board that displays available actions for the human player.

Source code in notty/src/visual/action_board.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
class ActionBoard:
    """Action board that displays available actions for the human player."""

    def __init__(
        self,
        screen: pygame.Surface,
        human_player_index: int,
        game: "VisualGame",
    ) -> None:
        """Initialize the action board.

        Args:
            screen: The pygame display surface.
            human_player_index: Index of the human player (0-2).
            game: The game instance for checking action availability.
        """
        self.screen = screen
        self.human_player_index = human_player_index
        self.game = game
        self.buttons: list[ActionButton] = []
        self._setup_buttons()

    def _setup_buttons(self) -> None:
        """Set up the action buttons."""
        # Use constants from consts.py for action board position and size
        board_x = ACTION_BOARD_X
        board_y = ACTION_BOARD_Y
        board_width = ACTION_BOARD_WIDTH
        board_height = ACTION_BOARD_HEIGHT

        # Define all possible actions
        actions = [
            ("Draw 1-3 Cards", Action.DRAW_MULTIPLE),
            ("Steal Card", Action.STEAL),
            ("Draw & Discard (Draw)", Action.DRAW_DISCARD_DRAW),
            ("Draw & Discard (Discard)", Action.DRAW_DISCARD_DISCARD),
            ("Discard Group", Action.DISCARD_GROUP),
            ("Next Turn", Action.NEXT_TURN),
            ("Play for Me", Action.PLAY_FOR_ME),
        ]

        # Calculate button dimensions based on action board size and number of buttons
        num_buttons = len(actions)
        button_spacing = 10
        total_spacing = button_spacing * (
            num_buttons + 1
        )  # Spacing above, below, and between buttons

        # Button width is board width minus padding on both sides
        button_padding = 15
        button_width = board_width - (2 * button_padding)

        # Button height is calculated to fit all buttons with spacing
        button_height = (board_height - total_spacing) // num_buttons

        # Create buttons
        for i, (text, action_name) in enumerate(actions):
            y = board_y + button_spacing + i * (button_height + button_spacing)
            x = board_x + button_padding
            button = ActionButton(
                x=x,
                y=y,
                width=button_width,
                height=button_height,
                text=text,
                action_name=action_name,
                enabled=False,  # Will be updated based on game state
            )
            self.buttons.append(button)

    def update_button_states(self, game: "VisualGame") -> None:
        """Update button enabled states based on current game state.

        Args:
            game: The game instance to check action availability.
        """
        current_player = game.get_current_player()
        is_human_turn = current_player.is_human

        for button in self.buttons:
            if not is_human_turn:
                button.enabled = False
            elif button.action_name == Action.PLAY_FOR_ME:
                # "Play for Me" button is always enabled during human's turn
                button.enabled = True
            else:
                button.enabled = game.action_is_possible(button.action_name)

    def update_hover(self, mouse_x: int, mouse_y: int) -> None:
        """Update hover state for all buttons.

        Args:
            mouse_x: Mouse x coordinate.
            mouse_y: Mouse y coordinate.
        """
        for button in self.buttons:
            button.update_hover(mouse_x, mouse_y)

    def handle_click(self, mouse_x: int, mouse_y: int) -> None:
        """Handle click on action board.

        Args:
            mouse_x: Mouse x coordinate.
            mouse_y: Mouse y coordinate.

        Returns:
            The action name if a button was clicked, None otherwise.
        """
        game = self.game
        for button in self.buttons:
            enabled = button.enabled
            clicked = button.is_clicked(mouse_x, mouse_y)
            action_name = button.action_name
            if enabled and clicked:
                game.do_action(action_name)

    def draw(self) -> None:
        """Draw the action board and all buttons.

        Automatically updates button states before drawing.
        """
        # Automatically update button states based on current game state
        self.update_button_states(self.game)

        # Draw background panel
        panel_padding = 15
        if self.buttons:
            first_button = self.buttons[0]
            last_button = self.buttons[-1]

            panel_x = first_button.x - panel_padding
            panel_y = first_button.y - panel_padding
            panel_width = first_button.width + 2 * panel_padding
            panel_height = (
                last_button.y + last_button.height - first_button.y + 2 * panel_padding
            )

            # Draw semi-transparent background
            panel_surface = pygame.Surface((panel_width, panel_height))
            panel_surface.set_alpha(200)
            panel_surface.fill((40, 40, 40))
            self.screen.blit(panel_surface, (panel_x, panel_y))

            # Draw border
            pygame.draw.rect(
                self.screen,
                (200, 200, 200),
                (panel_x, panel_y, panel_width, panel_height),
                3,
            )

            # Draw title - scale font size
            font_size = int(ACTION_BOARD_HEIGHT * 0.04)  # 4% of action board height
            font = pygame.font.Font(None, font_size)
            title_text = font.render("Actions", ANTI_ALIASING, (255, 255, 255))
            title_rect = title_text.get_rect(
                center=(
                    panel_x + panel_width // 2,
                    panel_y - int(ACTION_BOARD_HEIGHT * 0.025),
                )
            )
            self.screen.blit(title_text, title_rect)

        # Draw all buttons
        for button in self.buttons:
            button.draw(self.screen)
__init__(screen, human_player_index, game)

Initialize the action board.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required
human_player_index int

Index of the human player (0-2).

required
game VisualGame

The game instance for checking action availability.

required
Source code in notty/src/visual/action_board.py
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def __init__(
    self,
    screen: pygame.Surface,
    human_player_index: int,
    game: "VisualGame",
) -> None:
    """Initialize the action board.

    Args:
        screen: The pygame display surface.
        human_player_index: Index of the human player (0-2).
        game: The game instance for checking action availability.
    """
    self.screen = screen
    self.human_player_index = human_player_index
    self.game = game
    self.buttons: list[ActionButton] = []
    self._setup_buttons()
_setup_buttons()

Set up the action buttons.

Source code in notty/src/visual/action_board.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
def _setup_buttons(self) -> None:
    """Set up the action buttons."""
    # Use constants from consts.py for action board position and size
    board_x = ACTION_BOARD_X
    board_y = ACTION_BOARD_Y
    board_width = ACTION_BOARD_WIDTH
    board_height = ACTION_BOARD_HEIGHT

    # Define all possible actions
    actions = [
        ("Draw 1-3 Cards", Action.DRAW_MULTIPLE),
        ("Steal Card", Action.STEAL),
        ("Draw & Discard (Draw)", Action.DRAW_DISCARD_DRAW),
        ("Draw & Discard (Discard)", Action.DRAW_DISCARD_DISCARD),
        ("Discard Group", Action.DISCARD_GROUP),
        ("Next Turn", Action.NEXT_TURN),
        ("Play for Me", Action.PLAY_FOR_ME),
    ]

    # Calculate button dimensions based on action board size and number of buttons
    num_buttons = len(actions)
    button_spacing = 10
    total_spacing = button_spacing * (
        num_buttons + 1
    )  # Spacing above, below, and between buttons

    # Button width is board width minus padding on both sides
    button_padding = 15
    button_width = board_width - (2 * button_padding)

    # Button height is calculated to fit all buttons with spacing
    button_height = (board_height - total_spacing) // num_buttons

    # Create buttons
    for i, (text, action_name) in enumerate(actions):
        y = board_y + button_spacing + i * (button_height + button_spacing)
        x = board_x + button_padding
        button = ActionButton(
            x=x,
            y=y,
            width=button_width,
            height=button_height,
            text=text,
            action_name=action_name,
            enabled=False,  # Will be updated based on game state
        )
        self.buttons.append(button)
draw()

Draw the action board and all buttons.

Automatically updates button states before drawing.

Source code in notty/src/visual/action_board.py
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
def draw(self) -> None:
    """Draw the action board and all buttons.

    Automatically updates button states before drawing.
    """
    # Automatically update button states based on current game state
    self.update_button_states(self.game)

    # Draw background panel
    panel_padding = 15
    if self.buttons:
        first_button = self.buttons[0]
        last_button = self.buttons[-1]

        panel_x = first_button.x - panel_padding
        panel_y = first_button.y - panel_padding
        panel_width = first_button.width + 2 * panel_padding
        panel_height = (
            last_button.y + last_button.height - first_button.y + 2 * panel_padding
        )

        # Draw semi-transparent background
        panel_surface = pygame.Surface((panel_width, panel_height))
        panel_surface.set_alpha(200)
        panel_surface.fill((40, 40, 40))
        self.screen.blit(panel_surface, (panel_x, panel_y))

        # Draw border
        pygame.draw.rect(
            self.screen,
            (200, 200, 200),
            (panel_x, panel_y, panel_width, panel_height),
            3,
        )

        # Draw title - scale font size
        font_size = int(ACTION_BOARD_HEIGHT * 0.04)  # 4% of action board height
        font = pygame.font.Font(None, font_size)
        title_text = font.render("Actions", ANTI_ALIASING, (255, 255, 255))
        title_rect = title_text.get_rect(
            center=(
                panel_x + panel_width // 2,
                panel_y - int(ACTION_BOARD_HEIGHT * 0.025),
            )
        )
        self.screen.blit(title_text, title_rect)

    # Draw all buttons
    for button in self.buttons:
        button.draw(self.screen)
handle_click(mouse_x, mouse_y)

Handle click on action board.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required

Returns:

Type Description
None

The action name if a button was clicked, None otherwise.

Source code in notty/src/visual/action_board.py
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
def handle_click(self, mouse_x: int, mouse_y: int) -> None:
    """Handle click on action board.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.

    Returns:
        The action name if a button was clicked, None otherwise.
    """
    game = self.game
    for button in self.buttons:
        enabled = button.enabled
        clicked = button.is_clicked(mouse_x, mouse_y)
        action_name = button.action_name
        if enabled and clicked:
            game.do_action(action_name)
update_button_states(game)

Update button enabled states based on current game state.

Parameters:

Name Type Description Default
game VisualGame

The game instance to check action availability.

required
Source code in notty/src/visual/action_board.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
def update_button_states(self, game: "VisualGame") -> None:
    """Update button enabled states based on current game state.

    Args:
        game: The game instance to check action availability.
    """
    current_player = game.get_current_player()
    is_human_turn = current_player.is_human

    for button in self.buttons:
        if not is_human_turn:
            button.enabled = False
        elif button.action_name == Action.PLAY_FOR_ME:
            # "Play for Me" button is always enabled during human's turn
            button.enabled = True
        else:
            button.enabled = game.action_is_possible(button.action_name)
update_hover(mouse_x, mouse_y)

Update hover state for all buttons.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required
Source code in notty/src/visual/action_board.py
240
241
242
243
244
245
246
247
248
def update_hover(self, mouse_x: int, mouse_y: int) -> None:
    """Update hover state for all buttons.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.
    """
    for button in self.buttons:
        button.update_hover(mouse_x, mouse_y)
ActionButton

Represents a clickable action button.

Source code in notty/src/visual/action_board.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
class ActionButton:
    """Represents a clickable action button."""

    def __init__(  # noqa: PLR0913
        self,
        x: int,
        y: int,
        width: int,
        height: int,
        text: str,
        action_name: str,
        *,
        enabled: bool = True,
    ) -> None:
        """Initialize an action button.

        Args:
            x: X coordinate of the button.
            y: Y coordinate of the button.
            width: Width of the button.
            height: Height of the button.
            text: Text to display on the button.
            action_name: Name of the action this button represents.
            enabled: Whether the button is enabled.
        """
        self.x = x
        self.y = y
        self.width = width
        self.height = height
        self.text = text
        self.action_name = action_name
        self.enabled = enabled
        self.hovered = False

    def is_clicked(self, mouse_x: int, mouse_y: int) -> bool:
        """Check if the button was clicked.

        Args:
            mouse_x: Mouse x coordinate.
            mouse_y: Mouse y coordinate.

        Returns:
            True if the button was clicked and is enabled.
        """
        if not self.enabled:
            return False
        return (
            self.x <= mouse_x <= self.x + self.width
            and self.y <= mouse_y <= self.y + self.height
        )

    def update_hover(self, mouse_x: int, mouse_y: int) -> None:
        """Update hover state based on mouse position.

        Args:
            mouse_x: Mouse x coordinate.
            mouse_y: Mouse y coordinate.
        """
        self.hovered = (
            self.x <= mouse_x <= self.x + self.width
            and self.y <= mouse_y <= self.y + self.height
        )

    def draw(self, screen: pygame.Surface) -> None:
        """Draw the button.

        Args:
            screen: The pygame display surface.
        """
        # Determine button color based on state
        if not self.enabled:
            bg_color = (100, 100, 100)  # Gray for disabled
            text_color = (150, 150, 150)  # Light gray text
            border_color = (80, 80, 80)
            alpha = 100  # Faded out
        elif self.hovered:
            bg_color = (100, 200, 255)  # Light blue for hover
            text_color = (0, 0, 0)  # Black text
            border_color = (50, 150, 255)
            alpha = 255  # Fully visible
        else:
            bg_color = (50, 150, 50)  # Green for enabled
            text_color = (255, 255, 255)  # White text
            border_color = (30, 100, 30)
            alpha = 255  # Fully visible

        # Create a surface for the button with alpha channel
        button_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA)

        # Draw button background on the surface
        pygame.draw.rect(button_surface, bg_color, (0, 0, self.width, self.height))

        # Draw button border on the surface
        pygame.draw.rect(
            button_surface, border_color, (0, 0, self.width, self.height), 3
        )

        # Draw button text on the surface - scale font size based on button height
        font_size = int(self.height * 0.5)  # 70% of button height (doubled from 35%)
        font = pygame.font.Font(None, font_size)
        text_surface = font.render(self.text, ANTI_ALIASING, text_color)
        text_rect = text_surface.get_rect(center=(self.width // 2, self.height // 2))
        button_surface.blit(text_surface, text_rect)

        # Set alpha and blit to screen
        button_surface.set_alpha(alpha)
        screen.blit(button_surface, (self.x, self.y))
__init__(x, y, width, height, text, action_name, *, enabled=True)

Initialize an action button.

Parameters:

Name Type Description Default
x int

X coordinate of the button.

required
y int

Y coordinate of the button.

required
width int

Width of the button.

required
height int

Height of the button.

required
text str

Text to display on the button.

required
action_name str

Name of the action this button represents.

required
enabled bool

Whether the button is enabled.

True
Source code in notty/src/visual/action_board.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def __init__(  # noqa: PLR0913
    self,
    x: int,
    y: int,
    width: int,
    height: int,
    text: str,
    action_name: str,
    *,
    enabled: bool = True,
) -> None:
    """Initialize an action button.

    Args:
        x: X coordinate of the button.
        y: Y coordinate of the button.
        width: Width of the button.
        height: Height of the button.
        text: Text to display on the button.
        action_name: Name of the action this button represents.
        enabled: Whether the button is enabled.
    """
    self.x = x
    self.y = y
    self.width = width
    self.height = height
    self.text = text
    self.action_name = action_name
    self.enabled = enabled
    self.hovered = False
draw(screen)

Draw the button.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required
Source code in notty/src/visual/action_board.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
def draw(self, screen: pygame.Surface) -> None:
    """Draw the button.

    Args:
        screen: The pygame display surface.
    """
    # Determine button color based on state
    if not self.enabled:
        bg_color = (100, 100, 100)  # Gray for disabled
        text_color = (150, 150, 150)  # Light gray text
        border_color = (80, 80, 80)
        alpha = 100  # Faded out
    elif self.hovered:
        bg_color = (100, 200, 255)  # Light blue for hover
        text_color = (0, 0, 0)  # Black text
        border_color = (50, 150, 255)
        alpha = 255  # Fully visible
    else:
        bg_color = (50, 150, 50)  # Green for enabled
        text_color = (255, 255, 255)  # White text
        border_color = (30, 100, 30)
        alpha = 255  # Fully visible

    # Create a surface for the button with alpha channel
    button_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA)

    # Draw button background on the surface
    pygame.draw.rect(button_surface, bg_color, (0, 0, self.width, self.height))

    # Draw button border on the surface
    pygame.draw.rect(
        button_surface, border_color, (0, 0, self.width, self.height), 3
    )

    # Draw button text on the surface - scale font size based on button height
    font_size = int(self.height * 0.5)  # 70% of button height (doubled from 35%)
    font = pygame.font.Font(None, font_size)
    text_surface = font.render(self.text, ANTI_ALIASING, text_color)
    text_rect = text_surface.get_rect(center=(self.width // 2, self.height // 2))
    button_surface.blit(text_surface, text_rect)

    # Set alpha and blit to screen
    button_surface.set_alpha(alpha)
    screen.blit(button_surface, (self.x, self.y))
is_clicked(mouse_x, mouse_y)

Check if the button was clicked.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required

Returns:

Type Description
bool

True if the button was clicked and is enabled.

Source code in notty/src/visual/action_board.py
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def is_clicked(self, mouse_x: int, mouse_y: int) -> bool:
    """Check if the button was clicked.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.

    Returns:
        True if the button was clicked and is enabled.
    """
    if not self.enabled:
        return False
    return (
        self.x <= mouse_x <= self.x + self.width
        and self.y <= mouse_y <= self.y + self.height
    )
update_hover(mouse_x, mouse_y)

Update hover state based on mouse position.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required
Source code in notty/src/visual/action_board.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def update_hover(self, mouse_x: int, mouse_y: int) -> None:
    """Update hover state based on mouse position.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.
    """
    self.hovered = (
        self.x <= mouse_x <= self.x + self.width
        and self.y <= mouse_y <= self.y + self.height
    )

base

Contains a base clöass Visual to represent a visual element.

Visual

Bases: ABC

Base class for all visual elements.

Source code in notty/src/visual/base.py
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
class Visual(ABC):
    """Base class for all visual elements."""

    def __init__(
        self,
        x: int,
        y: int,
        height: int,
        width: int,
        screen: pygame.Surface,
    ) -> None:
        """Initialize a visual element.

        Args:
            x: X coordinate. Always represents the top-left corner.
            y: Y coordinate. Always represents the top-left corner.
            height: Height of the visual element.
            width: Width of the visual element.
            screen: The pygame display surface.
        """
        self.x = x
        self.y = y
        self.target_x = x
        self.target_y = y
        self.height = height
        self.width = width
        self.screen = screen

        png = pygame.image.load(self.get_png_path())
        self.png = pygame.transform.scale(png, (self.width, self.height))

    def get_center(self) -> tuple[int, int]:
        """Get the center of the visual element."""
        return self.x + self.width // 2, self.y + self.height // 2

    def move(self, x: int, y: int) -> None:
        """Move the visual element.

        Animates the movement in a straight line to that given location.

        Args:
            x: X coordinate.
            y: Y coordinate.
        """
        self.target_x = x
        self.target_y = y

    def set_position(self, x: int, y: int) -> None:
        """Set the position of the visual element.

        Args:
            x: X coordinate.
            y: Y coordinate.
        """
        self.x = x
        self.y = y

    def draw(self) -> None:
        """Draw the visual element.

        Args:
            screen: The pygame display surface.
        """
        # Smoothly move toward target
        dx = self.target_x - self.x
        dy = self.target_y - self.y
        distance = (dx**2 + dy**2) ** 0.5

        if distance > ANIMATION_SPEED:
            # Move toward target at constant speed
            self.x += (dx / distance) * ANIMATION_SPEED
            self.y += (dy / distance) * ANIMATION_SPEED
        else:
            # Snap to target when close enough
            self.x = self.target_x
            self.y = self.target_y

        # Draw the image at current position
        self.screen.blit(self.png, (self.x, self.y))

    def get_png_path(self) -> Path:
        """Get the png for the visual element."""
        return resource_path(self.get_png_name() + ".png", self.get_png_pkg())

    @abstractmethod
    def get_png_name(self) -> str:
        """Get the png for the visual element."""

    @abstractmethod
    def get_png_pkg(self) -> ModuleType:
        """Get the png for the visual element."""
__init__(x, y, height, width, screen)

Initialize a visual element.

Parameters:

Name Type Description Default
x int

X coordinate. Always represents the top-left corner.

required
y int

Y coordinate. Always represents the top-left corner.

required
height int

Height of the visual element.

required
width int

Width of the visual element.

required
screen Surface

The pygame display surface.

required
Source code in notty/src/visual/base.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def __init__(
    self,
    x: int,
    y: int,
    height: int,
    width: int,
    screen: pygame.Surface,
) -> None:
    """Initialize a visual element.

    Args:
        x: X coordinate. Always represents the top-left corner.
        y: Y coordinate. Always represents the top-left corner.
        height: Height of the visual element.
        width: Width of the visual element.
        screen: The pygame display surface.
    """
    self.x = x
    self.y = y
    self.target_x = x
    self.target_y = y
    self.height = height
    self.width = width
    self.screen = screen

    png = pygame.image.load(self.get_png_path())
    self.png = pygame.transform.scale(png, (self.width, self.height))
draw()

Draw the visual element.

Parameters:

Name Type Description Default
screen

The pygame display surface.

required
Source code in notty/src/visual/base.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def draw(self) -> None:
    """Draw the visual element.

    Args:
        screen: The pygame display surface.
    """
    # Smoothly move toward target
    dx = self.target_x - self.x
    dy = self.target_y - self.y
    distance = (dx**2 + dy**2) ** 0.5

    if distance > ANIMATION_SPEED:
        # Move toward target at constant speed
        self.x += (dx / distance) * ANIMATION_SPEED
        self.y += (dy / distance) * ANIMATION_SPEED
    else:
        # Snap to target when close enough
        self.x = self.target_x
        self.y = self.target_y

    # Draw the image at current position
    self.screen.blit(self.png, (self.x, self.y))
get_center()

Get the center of the visual element.

Source code in notty/src/visual/base.py
44
45
46
def get_center(self) -> tuple[int, int]:
    """Get the center of the visual element."""
    return self.x + self.width // 2, self.y + self.height // 2
get_png_name() abstractmethod

Get the png for the visual element.

Source code in notty/src/visual/base.py
97
98
99
@abstractmethod
def get_png_name(self) -> str:
    """Get the png for the visual element."""
get_png_path()

Get the png for the visual element.

Source code in notty/src/visual/base.py
93
94
95
def get_png_path(self) -> Path:
    """Get the png for the visual element."""
    return resource_path(self.get_png_name() + ".png", self.get_png_pkg())
get_png_pkg() abstractmethod

Get the png for the visual element.

Source code in notty/src/visual/base.py
101
102
103
@abstractmethod
def get_png_pkg(self) -> ModuleType:
    """Get the png for the visual element."""
move(x, y)

Move the visual element.

Animates the movement in a straight line to that given location.

Parameters:

Name Type Description Default
x int

X coordinate.

required
y int

Y coordinate.

required
Source code in notty/src/visual/base.py
48
49
50
51
52
53
54
55
56
57
58
def move(self, x: int, y: int) -> None:
    """Move the visual element.

    Animates the movement in a straight line to that given location.

    Args:
        x: X coordinate.
        y: Y coordinate.
    """
    self.target_x = x
    self.target_y = y
set_position(x, y)

Set the position of the visual element.

Parameters:

Name Type Description Default
x int

X coordinate.

required
y int

Y coordinate.

required
Source code in notty/src/visual/base.py
60
61
62
63
64
65
66
67
68
def set_position(self, x: int, y: int) -> None:
    """Set the position of the visual element.

    Args:
        x: X coordinate.
        y: Y coordinate.
    """
    self.x = x
    self.y = y

base_selector

Base selector dialog for choosing items with images.

BaseSelector

Bases: ABC

Base class for selector dialogs.

Source code in notty/src/visual/base_selector.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
class BaseSelector[T](ABC):
    """Base class for selector dialogs."""

    def __init__(
        self,
        screen: pygame.Surface,
        title: str,
        items: list[T],
        *,
        max_selections: int = 1,
        validation_func: Callable[[list[T]], bool] | None = None,
    ) -> None:
        """Initialize the base selector.

        Args:
            screen: The pygame display surface.
            title: Title to display in the dialog.
            items: List of items that can be selected.
            max_selections: Maximum number of items that can be selected
                (1 for single select).
            validation_func: Optional function to validate if selection is valid.
        """
        self.screen = screen
        self.title = title
        self.items = items
        self.max_selections = max_selections
        self.validation_func = validation_func
        self.buttons: list[SelectableButton[T]] = []
        self._setup_buttons()

    @abstractmethod
    def _setup_buttons(self) -> None:
        """Set up the selectable buttons. Must be implemented by subclasses."""

    @abstractmethod
    def _get_button_dimensions(self) -> tuple[int, int, int]:
        """Get button dimensions (width, height, spacing).

        Returns:
            Tuple of (button_width, button_height, button_spacing).
        """

    @abstractmethod
    def _get_dialog_dimensions(self) -> tuple[int, int]:
        """Get dialog dimensions.

        Returns:
            Tuple of (dialog_width, dialog_height).
        """

    def _get_selected_items(self) -> list[T]:
        """Get the list of currently selected items.

        Returns:
            List of selected items.
        """
        return [button.item for button in self.buttons if button.selected]

    def _is_valid_selection(self) -> bool:
        """Check if the current selection is valid.

        Returns:
            True if the selection is valid.
        """
        selected = self._get_selected_items()
        if not selected:
            return False
        if self.validation_func:
            return self.validation_func(selected)
        return len(selected) <= self.max_selections

    def show(self) -> list[T] | T | None:
        """Show the selector and wait for user input.

        Returns:
            The selected item(s). Returns single item if max_selections=1,
                list otherwise.
            Returns None if no selection was made.
        """
        clock = pygame.time.Clock()

        while True:
            # Handle events
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    pygame.quit()
                    raise SystemExit
                if event.type == pygame.MOUSEBUTTONDOWN:
                    mouse_x, mouse_y = pygame.mouse.get_pos()

                    # Check if any button was clicked
                    for button in self.buttons:
                        if button.is_clicked(mouse_x, mouse_y):
                            if self.max_selections == 1:
                                # Single selection - return immediately
                                return button.item
                            # Multi-selection - toggle selection
                            current_count = len(self._get_selected_items())
                            button.toggle_selection(current_count, self.max_selections)
                            break

            # Update hover state
            mouse_x, mouse_y = pygame.mouse.get_pos()
            for button in self.buttons:
                button.update_hover(mouse_x, mouse_y)

            # Draw
            self._draw()

            # Update display
            pygame.display.flip()
            clock.tick(60)  # 60 FPS

    def _draw(self) -> None:
        """Draw the selector dialog."""
        # Draw semi-transparent overlay
        overlay = pygame.Surface((int(APP_WIDTH), int(APP_HEIGHT)))
        overlay.set_alpha(200)
        overlay.fill((0, 0, 0))
        self.screen.blit(overlay, (0, 0))

        # Get dialog dimensions
        dialog_width, dialog_height = self._get_dialog_dimensions()
        dialog_x = int((APP_WIDTH - dialog_width) // 2)
        dialog_y = int((APP_HEIGHT - dialog_height) // 2)

        # Draw dialog background
        pygame.draw.rect(
            self.screen,
            (40, 40, 40),
            (dialog_x, dialog_y, dialog_width, dialog_height),
        )

        # Draw dialog border
        pygame.draw.rect(
            self.screen,
            (200, 200, 200),
            (dialog_x, dialog_y, dialog_width, dialog_height),
            3,
        )

        # Draw title
        font_size = int(APP_HEIGHT * 0.06)  # 6% of screen height
        font = pygame.font.Font(None, font_size)
        title_text = font.render(self.title, ANTI_ALIASING, (255, 255, 255))
        title_rect = title_text.get_rect(
            center=(int(APP_WIDTH // 2), dialog_y + int(APP_HEIGHT * 0.06))
        )
        self.screen.blit(title_text, title_rect)

        # Draw buttons
        for button in self.buttons:
            button.draw(self.screen)
__init__(screen, title, items, *, max_selections=1, validation_func=None)

Initialize the base selector.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required
title str

Title to display in the dialog.

required
items list[T]

List of items that can be selected.

required
max_selections int

Maximum number of items that can be selected (1 for single select).

1
validation_func Callable[[list[T]], bool] | None

Optional function to validate if selection is valid.

None
Source code in notty/src/visual/base_selector.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
def __init__(
    self,
    screen: pygame.Surface,
    title: str,
    items: list[T],
    *,
    max_selections: int = 1,
    validation_func: Callable[[list[T]], bool] | None = None,
) -> None:
    """Initialize the base selector.

    Args:
        screen: The pygame display surface.
        title: Title to display in the dialog.
        items: List of items that can be selected.
        max_selections: Maximum number of items that can be selected
            (1 for single select).
        validation_func: Optional function to validate if selection is valid.
    """
    self.screen = screen
    self.title = title
    self.items = items
    self.max_selections = max_selections
    self.validation_func = validation_func
    self.buttons: list[SelectableButton[T]] = []
    self._setup_buttons()
_draw()

Draw the selector dialog.

Source code in notty/src/visual/base_selector.py
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
def _draw(self) -> None:
    """Draw the selector dialog."""
    # Draw semi-transparent overlay
    overlay = pygame.Surface((int(APP_WIDTH), int(APP_HEIGHT)))
    overlay.set_alpha(200)
    overlay.fill((0, 0, 0))
    self.screen.blit(overlay, (0, 0))

    # Get dialog dimensions
    dialog_width, dialog_height = self._get_dialog_dimensions()
    dialog_x = int((APP_WIDTH - dialog_width) // 2)
    dialog_y = int((APP_HEIGHT - dialog_height) // 2)

    # Draw dialog background
    pygame.draw.rect(
        self.screen,
        (40, 40, 40),
        (dialog_x, dialog_y, dialog_width, dialog_height),
    )

    # Draw dialog border
    pygame.draw.rect(
        self.screen,
        (200, 200, 200),
        (dialog_x, dialog_y, dialog_width, dialog_height),
        3,
    )

    # Draw title
    font_size = int(APP_HEIGHT * 0.06)  # 6% of screen height
    font = pygame.font.Font(None, font_size)
    title_text = font.render(self.title, ANTI_ALIASING, (255, 255, 255))
    title_rect = title_text.get_rect(
        center=(int(APP_WIDTH // 2), dialog_y + int(APP_HEIGHT * 0.06))
    )
    self.screen.blit(title_text, title_rect)

    # Draw buttons
    for button in self.buttons:
        button.draw(self.screen)
_get_button_dimensions() abstractmethod

Get button dimensions (width, height, spacing).

Returns:

Type Description
tuple[int, int, int]

Tuple of (button_width, button_height, button_spacing).

Source code in notty/src/visual/base_selector.py
144
145
146
147
148
149
150
@abstractmethod
def _get_button_dimensions(self) -> tuple[int, int, int]:
    """Get button dimensions (width, height, spacing).

    Returns:
        Tuple of (button_width, button_height, button_spacing).
    """
_get_dialog_dimensions() abstractmethod

Get dialog dimensions.

Returns:

Type Description
tuple[int, int]

Tuple of (dialog_width, dialog_height).

Source code in notty/src/visual/base_selector.py
152
153
154
155
156
157
158
@abstractmethod
def _get_dialog_dimensions(self) -> tuple[int, int]:
    """Get dialog dimensions.

    Returns:
        Tuple of (dialog_width, dialog_height).
    """
_get_selected_items()

Get the list of currently selected items.

Returns:

Type Description
list[T]

List of selected items.

Source code in notty/src/visual/base_selector.py
160
161
162
163
164
165
166
def _get_selected_items(self) -> list[T]:
    """Get the list of currently selected items.

    Returns:
        List of selected items.
    """
    return [button.item for button in self.buttons if button.selected]
_is_valid_selection()

Check if the current selection is valid.

Returns:

Type Description
bool

True if the selection is valid.

Source code in notty/src/visual/base_selector.py
168
169
170
171
172
173
174
175
176
177
178
179
def _is_valid_selection(self) -> bool:
    """Check if the current selection is valid.

    Returns:
        True if the selection is valid.
    """
    selected = self._get_selected_items()
    if not selected:
        return False
    if self.validation_func:
        return self.validation_func(selected)
    return len(selected) <= self.max_selections
_setup_buttons() abstractmethod

Set up the selectable buttons. Must be implemented by subclasses.

Source code in notty/src/visual/base_selector.py
140
141
142
@abstractmethod
def _setup_buttons(self) -> None:
    """Set up the selectable buttons. Must be implemented by subclasses."""
show()

Show the selector and wait for user input.

Returns:

Type Description
list[T] | T | None

The selected item(s). Returns single item if max_selections=1, list otherwise.

list[T] | T | None

Returns None if no selection was made.

Source code in notty/src/visual/base_selector.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
def show(self) -> list[T] | T | None:
    """Show the selector and wait for user input.

    Returns:
        The selected item(s). Returns single item if max_selections=1,
            list otherwise.
        Returns None if no selection was made.
    """
    clock = pygame.time.Clock()

    while True:
        # Handle events
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.quit()
                raise SystemExit
            if event.type == pygame.MOUSEBUTTONDOWN:
                mouse_x, mouse_y = pygame.mouse.get_pos()

                # Check if any button was clicked
                for button in self.buttons:
                    if button.is_clicked(mouse_x, mouse_y):
                        if self.max_selections == 1:
                            # Single selection - return immediately
                            return button.item
                        # Multi-selection - toggle selection
                        current_count = len(self._get_selected_items())
                        button.toggle_selection(current_count, self.max_selections)
                        break

        # Update hover state
        mouse_x, mouse_y = pygame.mouse.get_pos()
        for button in self.buttons:
            button.update_hover(mouse_x, mouse_y)

        # Draw
        self._draw()

        # Update display
        pygame.display.flip()
        clock.tick(60)  # 60 FPS
SelectableButton

Bases: ABC

Base class for selectable buttons with images.

Source code in notty/src/visual/base_selector.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
class SelectableButton[T](ABC):
    """Base class for selectable buttons with images."""

    def __init__(  # noqa: PLR0913
        self,
        x: int,
        y: int,
        width: int,
        height: int,
        item: T,
        image: pygame.Surface | None = None,
        *,
        enabled: bool = True,
        selectable: bool = False,
    ) -> None:
        """Initialize a selectable button.

        Args:
            x: X coordinate of the button.
            y: Y coordinate of the button.
            width: Width of the button.
            height: Height of the button.
            item: The item this button represents.
            image: The image to display.
            enabled: Whether the button is enabled (clickable).
            selectable: Whether the button can be selected (for multi-select).
        """
        self.x = x
        self.y = y
        self.width = width
        self.height = height
        self.item = item
        self.image = image
        self.enabled = enabled
        self.selectable = selectable
        self.hovered = False
        self.selected = False

    def is_clicked(self, mouse_x: int, mouse_y: int) -> bool:
        """Check if the button was clicked.

        Args:
            mouse_x: Mouse x coordinate.
            mouse_y: Mouse y coordinate.

        Returns:
            True if the button was clicked and is enabled.
        """
        if not self.enabled:
            return False
        return (
            self.x <= mouse_x <= self.x + self.width
            and self.y <= mouse_y <= self.y + self.height
        )

    def update_hover(self, mouse_x: int, mouse_y: int) -> None:
        """Update hover state based on mouse position.

        Args:
            mouse_x: Mouse x coordinate.
            mouse_y: Mouse y coordinate.
        """
        if not self.enabled:
            self.hovered = False
            return
        self.hovered = (
            self.x <= mouse_x <= self.x + self.width
            and self.y <= mouse_y <= self.y + self.height
        )

    def toggle_selection(
        self, current_selected_count: int, max_selections: int
    ) -> None:
        """Toggle the selection state of this button.

        Args:
            current_selected_count: Number of currently selected items.
            max_selections: Maximum number of selections allowed.
        """
        if self.selectable:
            if self.selected:
                # Always allow deselection
                self.selected = False
            elif current_selected_count < max_selections:
                # Only allow selection if under max
                self.selected = True

    @abstractmethod
    def draw(self, screen: pygame.Surface) -> None:
        """Draw the button.

        Args:
            screen: The pygame display surface.
        """
__init__(x, y, width, height, item, image=None, *, enabled=True, selectable=False)

Initialize a selectable button.

Parameters:

Name Type Description Default
x int

X coordinate of the button.

required
y int

Y coordinate of the button.

required
width int

Width of the button.

required
height int

Height of the button.

required
item T

The item this button represents.

required
image Surface | None

The image to display.

None
enabled bool

Whether the button is enabled (clickable).

True
selectable bool

Whether the button can be selected (for multi-select).

False
Source code in notty/src/visual/base_selector.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def __init__(  # noqa: PLR0913
    self,
    x: int,
    y: int,
    width: int,
    height: int,
    item: T,
    image: pygame.Surface | None = None,
    *,
    enabled: bool = True,
    selectable: bool = False,
) -> None:
    """Initialize a selectable button.

    Args:
        x: X coordinate of the button.
        y: Y coordinate of the button.
        width: Width of the button.
        height: Height of the button.
        item: The item this button represents.
        image: The image to display.
        enabled: Whether the button is enabled (clickable).
        selectable: Whether the button can be selected (for multi-select).
    """
    self.x = x
    self.y = y
    self.width = width
    self.height = height
    self.item = item
    self.image = image
    self.enabled = enabled
    self.selectable = selectable
    self.hovered = False
    self.selected = False
draw(screen) abstractmethod

Draw the button.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required
Source code in notty/src/visual/base_selector.py
101
102
103
104
105
106
107
@abstractmethod
def draw(self, screen: pygame.Surface) -> None:
    """Draw the button.

    Args:
        screen: The pygame display surface.
    """
is_clicked(mouse_x, mouse_y)

Check if the button was clicked.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required

Returns:

Type Description
bool

True if the button was clicked and is enabled.

Source code in notty/src/visual/base_selector.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def is_clicked(self, mouse_x: int, mouse_y: int) -> bool:
    """Check if the button was clicked.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.

    Returns:
        True if the button was clicked and is enabled.
    """
    if not self.enabled:
        return False
    return (
        self.x <= mouse_x <= self.x + self.width
        and self.y <= mouse_y <= self.y + self.height
    )
toggle_selection(current_selected_count, max_selections)

Toggle the selection state of this button.

Parameters:

Name Type Description Default
current_selected_count int

Number of currently selected items.

required
max_selections int

Maximum number of selections allowed.

required
Source code in notty/src/visual/base_selector.py
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def toggle_selection(
    self, current_selected_count: int, max_selections: int
) -> None:
    """Toggle the selection state of this button.

    Args:
        current_selected_count: Number of currently selected items.
        max_selections: Maximum number of selections allowed.
    """
    if self.selectable:
        if self.selected:
            # Always allow deselection
            self.selected = False
        elif current_selected_count < max_selections:
            # Only allow selection if under max
            self.selected = True
update_hover(mouse_x, mouse_y)

Update hover state based on mouse position.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required
Source code in notty/src/visual/base_selector.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def update_hover(self, mouse_x: int, mouse_y: int) -> None:
    """Update hover state based on mouse position.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.
    """
    if not self.enabled:
        self.hovered = False
        return
    self.hovered = (
        self.x <= mouse_x <= self.x + self.width
        and self.y <= mouse_y <= self.y + self.height
    )

card

visual card.

Color

Color class for the Notty game.

Source code in notty/src/visual/card.py
13
14
15
16
17
18
19
20
21
22
23
24
25
class Color:
    """Color class for the Notty game."""

    RED = "red"
    GREEN = "green"
    YELLOW = "yellow"
    BLACK = "black"
    BLUE = "blue"

    @classmethod
    def get_all_colors(cls) -> set[str]:
        """Get all colors."""
        return {cls.RED, cls.GREEN, cls.YELLOW, cls.BLACK, cls.BLUE}
get_all_colors() classmethod

Get all colors.

Source code in notty/src/visual/card.py
22
23
24
25
@classmethod
def get_all_colors(cls) -> set[str]:
    """Get all colors."""
    return {cls.RED, cls.GREEN, cls.YELLOW, cls.BLACK, cls.BLUE}
Number

Number class for the Notty game.

Source code in notty/src/visual/card.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class Number:
    """Number class for the Notty game."""

    ONE = 1
    TWO = 2
    THREE = 3
    FOUR = 4
    FIVE = 5
    SIX = 6
    SEVEN = 7
    EIGHT = 8
    NINE = 9

    @classmethod
    def get_all_numbers(cls) -> range:
        """Get all numbers."""
        return range(1, 10)
get_all_numbers() classmethod

Get all numbers.

Source code in notty/src/visual/card.py
41
42
43
44
@classmethod
def get_all_numbers(cls) -> range:
    """Get all numbers."""
    return range(1, 10)
VisualCard

Bases: Visual

Visual card.

Source code in notty/src/visual/card.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
class VisualCard(Visual):
    """Visual card."""

    def __init__(
        self,
        color: str,
        number: int,
        x: int,
        y: int,
        screen: pygame.Surface,
    ) -> None:
        """Initialize a visual card.

        Args:
            color: The color of the card.
            number: The number of the card.
            x: X coordinate. Always represents the top-left corner.
            y: Y coordinate. Always represents the top-left corner.
            height: Height of the visual element.
            width: Width of the visual element.
            screen: The pygame display surface.
        """
        self.color = color
        self.number = number
        super().__init__(x, y, CARD_HEIGHT, CARD_WIDTH, screen)

    def get_png_name(self) -> str:
        """Get the png for the visual element."""
        return f"{self.number}"

    def get_png_pkg(self) -> ModuleType:
        """Get the png for the visual element."""
        card_mod_name = cards.__name__ + "." + self.color
        return import_module(str(card_mod_name))
__init__(color, number, x, y, screen)

Initialize a visual card.

Parameters:

Name Type Description Default
color str

The color of the card.

required
number int

The number of the card.

required
x int

X coordinate. Always represents the top-left corner.

required
y int

Y coordinate. Always represents the top-left corner.

required
height

Height of the visual element.

required
width

Width of the visual element.

required
screen Surface

The pygame display surface.

required
Source code in notty/src/visual/card.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def __init__(
    self,
    color: str,
    number: int,
    x: int,
    y: int,
    screen: pygame.Surface,
) -> None:
    """Initialize a visual card.

    Args:
        color: The color of the card.
        number: The number of the card.
        x: X coordinate. Always represents the top-left corner.
        y: Y coordinate. Always represents the top-left corner.
        height: Height of the visual element.
        width: Width of the visual element.
        screen: The pygame display surface.
    """
    self.color = color
    self.number = number
    super().__init__(x, y, CARD_HEIGHT, CARD_WIDTH, screen)
draw()

Draw the visual element.

Parameters:

Name Type Description Default
screen

The pygame display surface.

required
Source code in notty/src/visual/base.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def draw(self) -> None:
    """Draw the visual element.

    Args:
        screen: The pygame display surface.
    """
    # Smoothly move toward target
    dx = self.target_x - self.x
    dy = self.target_y - self.y
    distance = (dx**2 + dy**2) ** 0.5

    if distance > ANIMATION_SPEED:
        # Move toward target at constant speed
        self.x += (dx / distance) * ANIMATION_SPEED
        self.y += (dy / distance) * ANIMATION_SPEED
    else:
        # Snap to target when close enough
        self.x = self.target_x
        self.y = self.target_y

    # Draw the image at current position
    self.screen.blit(self.png, (self.x, self.y))
get_center()

Get the center of the visual element.

Source code in notty/src/visual/base.py
44
45
46
def get_center(self) -> tuple[int, int]:
    """Get the center of the visual element."""
    return self.x + self.width // 2, self.y + self.height // 2
get_png_name()

Get the png for the visual element.

Source code in notty/src/visual/card.py
73
74
75
def get_png_name(self) -> str:
    """Get the png for the visual element."""
    return f"{self.number}"
get_png_path()

Get the png for the visual element.

Source code in notty/src/visual/base.py
93
94
95
def get_png_path(self) -> Path:
    """Get the png for the visual element."""
    return resource_path(self.get_png_name() + ".png", self.get_png_pkg())
get_png_pkg()

Get the png for the visual element.

Source code in notty/src/visual/card.py
77
78
79
80
def get_png_pkg(self) -> ModuleType:
    """Get the png for the visual element."""
    card_mod_name = cards.__name__ + "." + self.color
    return import_module(str(card_mod_name))
move(x, y)

Move the visual element.

Animates the movement in a straight line to that given location.

Parameters:

Name Type Description Default
x int

X coordinate.

required
y int

Y coordinate.

required
Source code in notty/src/visual/base.py
48
49
50
51
52
53
54
55
56
57
58
def move(self, x: int, y: int) -> None:
    """Move the visual element.

    Animates the movement in a straight line to that given location.

    Args:
        x: X coordinate.
        y: Y coordinate.
    """
    self.target_x = x
    self.target_y = y
set_position(x, y)

Set the position of the visual element.

Parameters:

Name Type Description Default
x int

X coordinate.

required
y int

Y coordinate.

required
Source code in notty/src/visual/base.py
60
61
62
63
64
65
66
67
68
def set_position(self, x: int, y: int) -> None:
    """Set the position of the visual element.

    Args:
        x: X coordinate.
        y: Y coordinate.
    """
    self.x = x
    self.y = y

card_selector

Card selector dialog for choosing which card to discard.

CardButton

Bases: SelectableButton['VisualCard']

Represents a clickable card button.

Source code in notty/src/visual/card_selector.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
class CardButton(SelectableButton["VisualCard"]):
    """Represents a clickable card button."""

    def __init__(  # noqa: PLR0913
        self,
        x: int,
        y: int,
        width: int,
        height: int,
        card: "VisualCard",
        card_image: pygame.Surface,
    ) -> None:
        """Initialize a card button.

        Args:
            x: X coordinate of the button.
            y: Y coordinate of the button.
            width: Width of the button.
            height: Height of the button.
            card: The card this button represents.
            card_image: The image of the card.
        """
        super().__init__(
            x, y, width, height, card, card_image, enabled=True, selectable=False
        )
        self.card = card
        self.card_image = card_image

    def draw(self, screen: pygame.Surface) -> None:
        """Draw the button.

        Args:
            screen: The pygame display surface.
        """
        # Determine border color based on state
        if self.hovered:
            border_color = (100, 200, 255)  # Light blue for hover
            border_width = 5
        else:
            border_color = (255, 255, 255)  # White
            border_width = 3

        # Draw border
        border_padding = 5
        pygame.draw.rect(
            screen,
            border_color,
            (
                self.x - border_padding,
                self.y - border_padding,
                self.width + 2 * border_padding,
                self.height + 2 * border_padding,
            ),
            border_width,
        )

        # Draw card image
        screen.blit(self.card_image, (self.x, self.y))
__init__(x, y, width, height, card, card_image)

Initialize a card button.

Parameters:

Name Type Description Default
x int

X coordinate of the button.

required
y int

Y coordinate of the button.

required
width int

Width of the button.

required
height int

Height of the button.

required
card VisualCard

The card this button represents.

required
card_image Surface

The image of the card.

required
Source code in notty/src/visual/card_selector.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def __init__(  # noqa: PLR0913
    self,
    x: int,
    y: int,
    width: int,
    height: int,
    card: "VisualCard",
    card_image: pygame.Surface,
) -> None:
    """Initialize a card button.

    Args:
        x: X coordinate of the button.
        y: Y coordinate of the button.
        width: Width of the button.
        height: Height of the button.
        card: The card this button represents.
        card_image: The image of the card.
    """
    super().__init__(
        x, y, width, height, card, card_image, enabled=True, selectable=False
    )
    self.card = card
    self.card_image = card_image
draw(screen)

Draw the button.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required
Source code in notty/src/visual/card_selector.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def draw(self, screen: pygame.Surface) -> None:
    """Draw the button.

    Args:
        screen: The pygame display surface.
    """
    # Determine border color based on state
    if self.hovered:
        border_color = (100, 200, 255)  # Light blue for hover
        border_width = 5
    else:
        border_color = (255, 255, 255)  # White
        border_width = 3

    # Draw border
    border_padding = 5
    pygame.draw.rect(
        screen,
        border_color,
        (
            self.x - border_padding,
            self.y - border_padding,
            self.width + 2 * border_padding,
            self.height + 2 * border_padding,
        ),
        border_width,
    )

    # Draw card image
    screen.blit(self.card_image, (self.x, self.y))
is_clicked(mouse_x, mouse_y)

Check if the button was clicked.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required

Returns:

Type Description
bool

True if the button was clicked and is enabled.

Source code in notty/src/visual/base_selector.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def is_clicked(self, mouse_x: int, mouse_y: int) -> bool:
    """Check if the button was clicked.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.

    Returns:
        True if the button was clicked and is enabled.
    """
    if not self.enabled:
        return False
    return (
        self.x <= mouse_x <= self.x + self.width
        and self.y <= mouse_y <= self.y + self.height
    )
toggle_selection(current_selected_count, max_selections)

Toggle the selection state of this button.

Parameters:

Name Type Description Default
current_selected_count int

Number of currently selected items.

required
max_selections int

Maximum number of selections allowed.

required
Source code in notty/src/visual/base_selector.py
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def toggle_selection(
    self, current_selected_count: int, max_selections: int
) -> None:
    """Toggle the selection state of this button.

    Args:
        current_selected_count: Number of currently selected items.
        max_selections: Maximum number of selections allowed.
    """
    if self.selectable:
        if self.selected:
            # Always allow deselection
            self.selected = False
        elif current_selected_count < max_selections:
            # Only allow selection if under max
            self.selected = True
update_hover(mouse_x, mouse_y)

Update hover state based on mouse position.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required
Source code in notty/src/visual/base_selector.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def update_hover(self, mouse_x: int, mouse_y: int) -> None:
    """Update hover state based on mouse position.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.
    """
    if not self.enabled:
        self.hovered = False
        return
    self.hovered = (
        self.x <= mouse_x <= self.x + self.width
        and self.y <= mouse_y <= self.y + self.height
    )
CardSelector

Bases: BaseSelector['VisualCard']

Dialog for selecting a card to discard.

Source code in notty/src/visual/card_selector.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
class CardSelector(BaseSelector["VisualCard"]):
    """Dialog for selecting a card to discard."""

    def __init__(
        self, screen: pygame.Surface, available_cards: list["VisualCard"]
    ) -> None:
        """Initialize the card selector.

        Args:
            screen: The pygame display surface.
            available_cards: List of cards that can be selected.
        """
        super().__init__(
            screen,
            title="Choose a card to discard",
            items=available_cards,
            max_selections=1,
        )

    def _get_button_dimensions(self) -> tuple[int, int, int]:
        """Get button dimensions (width, height, spacing).

        Returns:
            Tuple of (button_width, button_height, button_spacing).
        """
        card_width = int(APP_WIDTH * 0.05)  # 5% of screen width
        card_height = int(APP_HEIGHT * 0.11)  # 11% of screen height
        card_spacing = int(APP_WIDTH * 0.008)  # 0.8% of screen width
        return card_width, card_height, card_spacing

    def _get_dialog_dimensions(self) -> tuple[int, int]:
        """Get dialog dimensions.

        Returns:
            Tuple of (dialog_width, dialog_height).
        """
        dialog_width = int(APP_WIDTH * 0.61)  # 61% of screen width
        dialog_height = int(APP_HEIGHT * 0.48)  # 48% of screen height
        return dialog_width, dialog_height

    def _setup_buttons(self) -> None:
        """Set up the card buttons."""
        # Get button dimensions
        card_width, card_height, card_spacing = self._get_button_dimensions()
        max_cards_per_row = 10

        # Calculate how many rows we need
        num_cards = len(self.items)
        num_rows = (num_cards + max_cards_per_row - 1) // max_cards_per_row

        # Calculate starting position
        start_y = int(APP_HEIGHT // 2 - (num_rows * (card_height + card_spacing)) // 2)

        # Create buttons for each card
        for i, card in enumerate(self.items):
            row = i // max_cards_per_row
            col = i % max_cards_per_row
            cards_in_row = min(max_cards_per_row, num_cards - row * max_cards_per_row)
            row_width = cards_in_row * card_width + (cards_in_row - 1) * card_spacing
            start_x = int((APP_WIDTH - row_width) // 2)

            x = start_x + col * (card_width + card_spacing)
            y = start_y + row * (card_height + card_spacing)

            # Scale card image
            card_image = pygame.transform.scale(card.png, (card_width, card_height))
            button = CardButton(x, y, card_width, card_height, card, card_image)
            self.buttons.append(button)
__init__(screen, available_cards)

Initialize the card selector.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required
available_cards list[VisualCard]

List of cards that can be selected.

required
Source code in notty/src/visual/card_selector.py
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def __init__(
    self, screen: pygame.Surface, available_cards: list["VisualCard"]
) -> None:
    """Initialize the card selector.

    Args:
        screen: The pygame display surface.
        available_cards: List of cards that can be selected.
    """
    super().__init__(
        screen,
        title="Choose a card to discard",
        items=available_cards,
        max_selections=1,
    )
_draw()

Draw the selector dialog.

Source code in notty/src/visual/base_selector.py
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
def _draw(self) -> None:
    """Draw the selector dialog."""
    # Draw semi-transparent overlay
    overlay = pygame.Surface((int(APP_WIDTH), int(APP_HEIGHT)))
    overlay.set_alpha(200)
    overlay.fill((0, 0, 0))
    self.screen.blit(overlay, (0, 0))

    # Get dialog dimensions
    dialog_width, dialog_height = self._get_dialog_dimensions()
    dialog_x = int((APP_WIDTH - dialog_width) // 2)
    dialog_y = int((APP_HEIGHT - dialog_height) // 2)

    # Draw dialog background
    pygame.draw.rect(
        self.screen,
        (40, 40, 40),
        (dialog_x, dialog_y, dialog_width, dialog_height),
    )

    # Draw dialog border
    pygame.draw.rect(
        self.screen,
        (200, 200, 200),
        (dialog_x, dialog_y, dialog_width, dialog_height),
        3,
    )

    # Draw title
    font_size = int(APP_HEIGHT * 0.06)  # 6% of screen height
    font = pygame.font.Font(None, font_size)
    title_text = font.render(self.title, ANTI_ALIASING, (255, 255, 255))
    title_rect = title_text.get_rect(
        center=(int(APP_WIDTH // 2), dialog_y + int(APP_HEIGHT * 0.06))
    )
    self.screen.blit(title_text, title_rect)

    # Draw buttons
    for button in self.buttons:
        button.draw(self.screen)
_get_button_dimensions()

Get button dimensions (width, height, spacing).

Returns:

Type Description
tuple[int, int, int]

Tuple of (button_width, button_height, button_spacing).

Source code in notty/src/visual/card_selector.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
def _get_button_dimensions(self) -> tuple[int, int, int]:
    """Get button dimensions (width, height, spacing).

    Returns:
        Tuple of (button_width, button_height, button_spacing).
    """
    card_width = int(APP_WIDTH * 0.05)  # 5% of screen width
    card_height = int(APP_HEIGHT * 0.11)  # 11% of screen height
    card_spacing = int(APP_WIDTH * 0.008)  # 0.8% of screen width
    return card_width, card_height, card_spacing
_get_dialog_dimensions()

Get dialog dimensions.

Returns:

Type Description
tuple[int, int]

Tuple of (dialog_width, dialog_height).

Source code in notty/src/visual/card_selector.py
104
105
106
107
108
109
110
111
112
def _get_dialog_dimensions(self) -> tuple[int, int]:
    """Get dialog dimensions.

    Returns:
        Tuple of (dialog_width, dialog_height).
    """
    dialog_width = int(APP_WIDTH * 0.61)  # 61% of screen width
    dialog_height = int(APP_HEIGHT * 0.48)  # 48% of screen height
    return dialog_width, dialog_height
_get_selected_items()

Get the list of currently selected items.

Returns:

Type Description
list[T]

List of selected items.

Source code in notty/src/visual/base_selector.py
160
161
162
163
164
165
166
def _get_selected_items(self) -> list[T]:
    """Get the list of currently selected items.

    Returns:
        List of selected items.
    """
    return [button.item for button in self.buttons if button.selected]
_is_valid_selection()

Check if the current selection is valid.

Returns:

Type Description
bool

True if the selection is valid.

Source code in notty/src/visual/base_selector.py
168
169
170
171
172
173
174
175
176
177
178
179
def _is_valid_selection(self) -> bool:
    """Check if the current selection is valid.

    Returns:
        True if the selection is valid.
    """
    selected = self._get_selected_items()
    if not selected:
        return False
    if self.validation_func:
        return self.validation_func(selected)
    return len(selected) <= self.max_selections
_setup_buttons()

Set up the card buttons.

Source code in notty/src/visual/card_selector.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
def _setup_buttons(self) -> None:
    """Set up the card buttons."""
    # Get button dimensions
    card_width, card_height, card_spacing = self._get_button_dimensions()
    max_cards_per_row = 10

    # Calculate how many rows we need
    num_cards = len(self.items)
    num_rows = (num_cards + max_cards_per_row - 1) // max_cards_per_row

    # Calculate starting position
    start_y = int(APP_HEIGHT // 2 - (num_rows * (card_height + card_spacing)) // 2)

    # Create buttons for each card
    for i, card in enumerate(self.items):
        row = i // max_cards_per_row
        col = i % max_cards_per_row
        cards_in_row = min(max_cards_per_row, num_cards - row * max_cards_per_row)
        row_width = cards_in_row * card_width + (cards_in_row - 1) * card_spacing
        start_x = int((APP_WIDTH - row_width) // 2)

        x = start_x + col * (card_width + card_spacing)
        y = start_y + row * (card_height + card_spacing)

        # Scale card image
        card_image = pygame.transform.scale(card.png, (card_width, card_height))
        button = CardButton(x, y, card_width, card_height, card, card_image)
        self.buttons.append(button)
show()

Show the selector and wait for user input.

Returns:

Type Description
list[T] | T | None

The selected item(s). Returns single item if max_selections=1, list otherwise.

list[T] | T | None

Returns None if no selection was made.

Source code in notty/src/visual/base_selector.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
def show(self) -> list[T] | T | None:
    """Show the selector and wait for user input.

    Returns:
        The selected item(s). Returns single item if max_selections=1,
            list otherwise.
        Returns None if no selection was made.
    """
    clock = pygame.time.Clock()

    while True:
        # Handle events
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.quit()
                raise SystemExit
            if event.type == pygame.MOUSEBUTTONDOWN:
                mouse_x, mouse_y = pygame.mouse.get_pos()

                # Check if any button was clicked
                for button in self.buttons:
                    if button.is_clicked(mouse_x, mouse_y):
                        if self.max_selections == 1:
                            # Single selection - return immediately
                            return button.item
                        # Multi-selection - toggle selection
                        current_count = len(self._get_selected_items())
                        button.toggle_selection(current_count, self.max_selections)
                        break

        # Update hover state
        mouse_x, mouse_y = pygame.mouse.get_pos()
        for button in self.buttons:
            button.update_hover(mouse_x, mouse_y)

        # Draw
        self._draw()

        # Update display
        pygame.display.flip()
        clock.tick(60)  # 60 FPS

cards_selector

Cards selector dialog for choosing multiple cards to discard as a group.

CardsSelector

Bases: BaseSelector['VisualCard']

Dialog for selecting multiple cards to discard as a group.

Source code in notty/src/visual/cards_selector.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
class CardsSelector(BaseSelector["VisualCard"]):
    """Dialog for selecting multiple cards to discard as a group."""

    def __init__(
        self,
        screen: pygame.Surface,
        available_cards: list["VisualCard"],
        validation_func: Callable[[list["VisualCard"]], bool],
    ) -> None:
        """Initialize the cards selector.

        Args:
            screen: The pygame display surface.
            available_cards: List of cards that can be selected.
            validation_func: Function to validate if selected cards form a valid group.
        """
        self.submit_button: SubmitButton | None = None
        super().__init__(
            screen,
            title="Choose cards to discard as a group",
            items=available_cards,
            max_selections=len(available_cards),  # Can select all cards
            validation_func=validation_func,
        )

    def _get_button_dimensions(self) -> tuple[int, int, int]:
        """Get button dimensions (width, height, spacing).

        Returns:
            Tuple of (button_width, button_height, button_spacing).
        """
        card_width = int(APP_WIDTH * 0.05)  # 5% of screen width
        card_height = int(APP_HEIGHT * 0.11)  # 11% of screen height
        card_spacing = int(APP_WIDTH * 0.008)  # 0.8% of screen width
        return card_width, card_height, card_spacing

    def _get_dialog_dimensions(self) -> tuple[int, int]:
        """Get dialog dimensions.

        Returns:
            Tuple of (dialog_width, dialog_height).
        """
        dialog_width = int(APP_WIDTH * 0.65)  # 65% of screen width
        dialog_height = int(APP_HEIGHT * 0.60)  # 60% of screen height
        return dialog_width, dialog_height

    def _setup_buttons(self) -> None:
        """Set up the card and submit buttons."""
        # Get button dimensions
        card_width, card_height, card_spacing = self._get_button_dimensions()
        max_cards_per_row = 10

        # Calculate how many rows we need
        num_cards = len(self.items)
        num_rows = (num_cards + max_cards_per_row - 1) // max_cards_per_row

        # Calculate starting position (leave room for submit button at bottom)
        start_y = int(
            APP_HEIGHT // 2
            - (num_rows * (card_height + card_spacing)) // 2
            - int(APP_HEIGHT * 0.06)
        )

        # Create buttons for each card
        for i, card in enumerate(self.items):
            row = i // max_cards_per_row
            col = i % max_cards_per_row
            cards_in_row = min(max_cards_per_row, num_cards - row * max_cards_per_row)
            row_width = cards_in_row * card_width + (cards_in_row - 1) * card_spacing
            start_x = int((APP_WIDTH - row_width) // 2)

            x = start_x + col * (card_width + card_spacing)
            y = start_y + row * (card_height + card_spacing)

            # Scale card image
            card_image = pygame.transform.scale(card.png, (card_width, card_height))
            button = MultiCardButton(x, y, card_width, card_height, card, card_image)
            self.buttons.append(button)

        # Create submit button - scale proportionally
        submit_width = int(APP_WIDTH * 0.17)  # 17% of screen width
        submit_height = int(APP_HEIGHT * 0.06)  # 6% of screen height
        submit_x = int((APP_WIDTH - submit_width) // 2)
        submit_y = int(APP_HEIGHT - int(APP_HEIGHT * 0.12))  # 12% from bottom
        self.submit_button = SubmitButton(
            submit_x, submit_y, submit_width, submit_height
        )

    def _update_submit_button_state(self) -> None:
        """Update the submit button enabled state based on selected cards."""
        if self.submit_button:
            self.submit_button.enabled = self._is_valid_selection()

    def show(self) -> list["VisualCard"]:
        """Show the cards selector and wait for user input.

        Returns:
            The list of selected cards.
        """
        clock = pygame.time.Clock()

        while True:
            # Handle events
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    pygame.quit()
                    raise SystemExit
                if event.type == pygame.MOUSEBUTTONDOWN:
                    mouse_x, mouse_y = pygame.mouse.get_pos()

                    # Check if submit button was clicked
                    if self.submit_button and self.submit_button.is_clicked(
                        mouse_x, mouse_y
                    ):
                        return self._get_selected_items()

                    # Check if any card button was clicked
                    for button in self.buttons:
                        if button.is_clicked(mouse_x, mouse_y):
                            button.toggle_selection(
                                len(self._get_selected_items()), self.max_selections
                            )
                            self._update_submit_button_state()
                            break

            # Update hover state
            mouse_x, mouse_y = pygame.mouse.get_pos()
            for button in self.buttons:
                button.update_hover(mouse_x, mouse_y)
            if self.submit_button:
                self.submit_button.update_hover(mouse_x, mouse_y)

            # Draw
            self._draw()

            # Update display
            pygame.display.flip()
            clock.tick(60)  # 60 FPS

    def _draw(self) -> None:
        """Draw the cards selector dialog."""
        # Call base class _draw to handle overlay, dialog, title, and buttons
        super()._draw()

        # Get dialog dimensions for positioning
        _dialog_width, dialog_height = self._get_dialog_dimensions()
        dialog_y = int((APP_HEIGHT - dialog_height) // 2)

        # Draw instruction - scale font size
        instruction_font_size = int(APP_HEIGHT * 0.034)  # 3.4% of screen height
        instruction_font = pygame.font.Font(None, instruction_font_size)
        selected_cards = self._get_selected_items()
        is_valid = self._is_valid_selection()

        if selected_cards:
            if is_valid:
                instruction = f"Selected {len(selected_cards)} cards - Valid group!"
                color = (50, 255, 50)  # Green
            else:
                instruction = f"Selected {len(selected_cards)} cards - Invalid group"
                color = (255, 50, 50)  # Red
        else:
            instruction = "Click cards to select/deselect"
            color = (200, 200, 200)  # Gray

        instruction_text = instruction_font.render(instruction, ANTI_ALIASING, color)
        instruction_rect = instruction_text.get_rect(
            center=(int(APP_WIDTH // 2), dialog_y + int(APP_HEIGHT * 0.10))
        )
        self.screen.blit(instruction_text, instruction_rect)

        # Draw submit button
        if self.submit_button:
            self.submit_button.draw(self.screen)
__init__(screen, available_cards, validation_func)

Initialize the cards selector.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required
available_cards list[VisualCard]

List of cards that can be selected.

required
validation_func Callable[[list[VisualCard]], bool]

Function to validate if selected cards form a valid group.

required
Source code in notty/src/visual/cards_selector.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def __init__(
    self,
    screen: pygame.Surface,
    available_cards: list["VisualCard"],
    validation_func: Callable[[list["VisualCard"]], bool],
) -> None:
    """Initialize the cards selector.

    Args:
        screen: The pygame display surface.
        available_cards: List of cards that can be selected.
        validation_func: Function to validate if selected cards form a valid group.
    """
    self.submit_button: SubmitButton | None = None
    super().__init__(
        screen,
        title="Choose cards to discard as a group",
        items=available_cards,
        max_selections=len(available_cards),  # Can select all cards
        validation_func=validation_func,
    )
_draw()

Draw the cards selector dialog.

Source code in notty/src/visual/cards_selector.py
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
def _draw(self) -> None:
    """Draw the cards selector dialog."""
    # Call base class _draw to handle overlay, dialog, title, and buttons
    super()._draw()

    # Get dialog dimensions for positioning
    _dialog_width, dialog_height = self._get_dialog_dimensions()
    dialog_y = int((APP_HEIGHT - dialog_height) // 2)

    # Draw instruction - scale font size
    instruction_font_size = int(APP_HEIGHT * 0.034)  # 3.4% of screen height
    instruction_font = pygame.font.Font(None, instruction_font_size)
    selected_cards = self._get_selected_items()
    is_valid = self._is_valid_selection()

    if selected_cards:
        if is_valid:
            instruction = f"Selected {len(selected_cards)} cards - Valid group!"
            color = (50, 255, 50)  # Green
        else:
            instruction = f"Selected {len(selected_cards)} cards - Invalid group"
            color = (255, 50, 50)  # Red
    else:
        instruction = "Click cards to select/deselect"
        color = (200, 200, 200)  # Gray

    instruction_text = instruction_font.render(instruction, ANTI_ALIASING, color)
    instruction_rect = instruction_text.get_rect(
        center=(int(APP_WIDTH // 2), dialog_y + int(APP_HEIGHT * 0.10))
    )
    self.screen.blit(instruction_text, instruction_rect)

    # Draw submit button
    if self.submit_button:
        self.submit_button.draw(self.screen)
_get_button_dimensions()

Get button dimensions (width, height, spacing).

Returns:

Type Description
tuple[int, int, int]

Tuple of (button_width, button_height, button_spacing).

Source code in notty/src/visual/cards_selector.py
205
206
207
208
209
210
211
212
213
214
def _get_button_dimensions(self) -> tuple[int, int, int]:
    """Get button dimensions (width, height, spacing).

    Returns:
        Tuple of (button_width, button_height, button_spacing).
    """
    card_width = int(APP_WIDTH * 0.05)  # 5% of screen width
    card_height = int(APP_HEIGHT * 0.11)  # 11% of screen height
    card_spacing = int(APP_WIDTH * 0.008)  # 0.8% of screen width
    return card_width, card_height, card_spacing
_get_dialog_dimensions()

Get dialog dimensions.

Returns:

Type Description
tuple[int, int]

Tuple of (dialog_width, dialog_height).

Source code in notty/src/visual/cards_selector.py
216
217
218
219
220
221
222
223
224
def _get_dialog_dimensions(self) -> tuple[int, int]:
    """Get dialog dimensions.

    Returns:
        Tuple of (dialog_width, dialog_height).
    """
    dialog_width = int(APP_WIDTH * 0.65)  # 65% of screen width
    dialog_height = int(APP_HEIGHT * 0.60)  # 60% of screen height
    return dialog_width, dialog_height
_get_selected_items()

Get the list of currently selected items.

Returns:

Type Description
list[T]

List of selected items.

Source code in notty/src/visual/base_selector.py
160
161
162
163
164
165
166
def _get_selected_items(self) -> list[T]:
    """Get the list of currently selected items.

    Returns:
        List of selected items.
    """
    return [button.item for button in self.buttons if button.selected]
_is_valid_selection()

Check if the current selection is valid.

Returns:

Type Description
bool

True if the selection is valid.

Source code in notty/src/visual/base_selector.py
168
169
170
171
172
173
174
175
176
177
178
179
def _is_valid_selection(self) -> bool:
    """Check if the current selection is valid.

    Returns:
        True if the selection is valid.
    """
    selected = self._get_selected_items()
    if not selected:
        return False
    if self.validation_func:
        return self.validation_func(selected)
    return len(selected) <= self.max_selections
_setup_buttons()

Set up the card and submit buttons.

Source code in notty/src/visual/cards_selector.py
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
def _setup_buttons(self) -> None:
    """Set up the card and submit buttons."""
    # Get button dimensions
    card_width, card_height, card_spacing = self._get_button_dimensions()
    max_cards_per_row = 10

    # Calculate how many rows we need
    num_cards = len(self.items)
    num_rows = (num_cards + max_cards_per_row - 1) // max_cards_per_row

    # Calculate starting position (leave room for submit button at bottom)
    start_y = int(
        APP_HEIGHT // 2
        - (num_rows * (card_height + card_spacing)) // 2
        - int(APP_HEIGHT * 0.06)
    )

    # Create buttons for each card
    for i, card in enumerate(self.items):
        row = i // max_cards_per_row
        col = i % max_cards_per_row
        cards_in_row = min(max_cards_per_row, num_cards - row * max_cards_per_row)
        row_width = cards_in_row * card_width + (cards_in_row - 1) * card_spacing
        start_x = int((APP_WIDTH - row_width) // 2)

        x = start_x + col * (card_width + card_spacing)
        y = start_y + row * (card_height + card_spacing)

        # Scale card image
        card_image = pygame.transform.scale(card.png, (card_width, card_height))
        button = MultiCardButton(x, y, card_width, card_height, card, card_image)
        self.buttons.append(button)

    # Create submit button - scale proportionally
    submit_width = int(APP_WIDTH * 0.17)  # 17% of screen width
    submit_height = int(APP_HEIGHT * 0.06)  # 6% of screen height
    submit_x = int((APP_WIDTH - submit_width) // 2)
    submit_y = int(APP_HEIGHT - int(APP_HEIGHT * 0.12))  # 12% from bottom
    self.submit_button = SubmitButton(
        submit_x, submit_y, submit_width, submit_height
    )
_update_submit_button_state()

Update the submit button enabled state based on selected cards.

Source code in notty/src/visual/cards_selector.py
268
269
270
271
def _update_submit_button_state(self) -> None:
    """Update the submit button enabled state based on selected cards."""
    if self.submit_button:
        self.submit_button.enabled = self._is_valid_selection()
show()

Show the cards selector and wait for user input.

Returns:

Type Description
list[VisualCard]

The list of selected cards.

Source code in notty/src/visual/cards_selector.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
def show(self) -> list["VisualCard"]:
    """Show the cards selector and wait for user input.

    Returns:
        The list of selected cards.
    """
    clock = pygame.time.Clock()

    while True:
        # Handle events
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.quit()
                raise SystemExit
            if event.type == pygame.MOUSEBUTTONDOWN:
                mouse_x, mouse_y = pygame.mouse.get_pos()

                # Check if submit button was clicked
                if self.submit_button and self.submit_button.is_clicked(
                    mouse_x, mouse_y
                ):
                    return self._get_selected_items()

                # Check if any card button was clicked
                for button in self.buttons:
                    if button.is_clicked(mouse_x, mouse_y):
                        button.toggle_selection(
                            len(self._get_selected_items()), self.max_selections
                        )
                        self._update_submit_button_state()
                        break

        # Update hover state
        mouse_x, mouse_y = pygame.mouse.get_pos()
        for button in self.buttons:
            button.update_hover(mouse_x, mouse_y)
        if self.submit_button:
            self.submit_button.update_hover(mouse_x, mouse_y)

        # Draw
        self._draw()

        # Update display
        pygame.display.flip()
        clock.tick(60)  # 60 FPS
MultiCardButton

Bases: SelectableButton['VisualCard']

Represents a clickable card button that can be selected/deselected.

Source code in notty/src/visual/cards_selector.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
class MultiCardButton(SelectableButton["VisualCard"]):
    """Represents a clickable card button that can be selected/deselected."""

    def __init__(  # noqa: PLR0913
        self,
        x: int,
        y: int,
        width: int,
        height: int,
        card: "VisualCard",
        card_image: pygame.Surface,
    ) -> None:
        """Initialize a multi-card button.

        Args:
            x: X coordinate of the button.
            y: Y coordinate of the button.
            width: Width of the button.
            height: Height of the button.
            card: The card this button represents.
            card_image: The image of the card.
        """
        super().__init__(
            x, y, width, height, card, card_image, enabled=True, selectable=True
        )
        self.card = card
        self.card_image = card_image

    def draw(self, screen: pygame.Surface) -> None:
        """Draw the button.

        Args:
            screen: The pygame display surface.
        """
        # Determine border color based on state
        if self.selected:
            border_color = (50, 255, 50)  # Green for selected
            border_width = 6
        elif self.hovered:
            border_color = (100, 200, 255)  # Light blue for hover
            border_width = 5
        else:
            border_color = (255, 255, 255)  # White
            border_width = 3

        # Draw border
        border_padding = 5
        pygame.draw.rect(
            screen,
            border_color,
            (
                self.x - border_padding,
                self.y - border_padding,
                self.width + 2 * border_padding,
                self.height + 2 * border_padding,
            ),
            border_width,
        )

        # Draw card image
        screen.blit(self.card_image, (self.x, self.y))

        # Draw checkmark if selected
        if self.selected:
            # Draw a semi-transparent green overlay
            overlay = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
            overlay.fill((50, 255, 50, 80))
            screen.blit(overlay, (self.x, self.y))

            # Draw checkmark - scale font size based on card height
            font_size = int(self.height * 0.53)  # 53% of card height
            font = pygame.font.Font(None, font_size)
            checkmark = font.render("✓", ANTI_ALIASING, (255, 255, 255))
            checkmark_rect = checkmark.get_rect(
                center=(self.x + self.width // 2, self.y + self.height // 2)
            )
            screen.blit(checkmark, checkmark_rect)
__init__(x, y, width, height, card, card_image)

Initialize a multi-card button.

Parameters:

Name Type Description Default
x int

X coordinate of the button.

required
y int

Y coordinate of the button.

required
width int

Width of the button.

required
height int

Height of the button.

required
card VisualCard

The card this button represents.

required
card_image Surface

The image of the card.

required
Source code in notty/src/visual/cards_selector.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def __init__(  # noqa: PLR0913
    self,
    x: int,
    y: int,
    width: int,
    height: int,
    card: "VisualCard",
    card_image: pygame.Surface,
) -> None:
    """Initialize a multi-card button.

    Args:
        x: X coordinate of the button.
        y: Y coordinate of the button.
        width: Width of the button.
        height: Height of the button.
        card: The card this button represents.
        card_image: The image of the card.
    """
    super().__init__(
        x, y, width, height, card, card_image, enabled=True, selectable=True
    )
    self.card = card
    self.card_image = card_image
draw(screen)

Draw the button.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required
Source code in notty/src/visual/cards_selector.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def draw(self, screen: pygame.Surface) -> None:
    """Draw the button.

    Args:
        screen: The pygame display surface.
    """
    # Determine border color based on state
    if self.selected:
        border_color = (50, 255, 50)  # Green for selected
        border_width = 6
    elif self.hovered:
        border_color = (100, 200, 255)  # Light blue for hover
        border_width = 5
    else:
        border_color = (255, 255, 255)  # White
        border_width = 3

    # Draw border
    border_padding = 5
    pygame.draw.rect(
        screen,
        border_color,
        (
            self.x - border_padding,
            self.y - border_padding,
            self.width + 2 * border_padding,
            self.height + 2 * border_padding,
        ),
        border_width,
    )

    # Draw card image
    screen.blit(self.card_image, (self.x, self.y))

    # Draw checkmark if selected
    if self.selected:
        # Draw a semi-transparent green overlay
        overlay = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
        overlay.fill((50, 255, 50, 80))
        screen.blit(overlay, (self.x, self.y))

        # Draw checkmark - scale font size based on card height
        font_size = int(self.height * 0.53)  # 53% of card height
        font = pygame.font.Font(None, font_size)
        checkmark = font.render("✓", ANTI_ALIASING, (255, 255, 255))
        checkmark_rect = checkmark.get_rect(
            center=(self.x + self.width // 2, self.y + self.height // 2)
        )
        screen.blit(checkmark, checkmark_rect)
is_clicked(mouse_x, mouse_y)

Check if the button was clicked.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required

Returns:

Type Description
bool

True if the button was clicked and is enabled.

Source code in notty/src/visual/base_selector.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def is_clicked(self, mouse_x: int, mouse_y: int) -> bool:
    """Check if the button was clicked.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.

    Returns:
        True if the button was clicked and is enabled.
    """
    if not self.enabled:
        return False
    return (
        self.x <= mouse_x <= self.x + self.width
        and self.y <= mouse_y <= self.y + self.height
    )
toggle_selection(current_selected_count, max_selections)

Toggle the selection state of this button.

Parameters:

Name Type Description Default
current_selected_count int

Number of currently selected items.

required
max_selections int

Maximum number of selections allowed.

required
Source code in notty/src/visual/base_selector.py
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def toggle_selection(
    self, current_selected_count: int, max_selections: int
) -> None:
    """Toggle the selection state of this button.

    Args:
        current_selected_count: Number of currently selected items.
        max_selections: Maximum number of selections allowed.
    """
    if self.selectable:
        if self.selected:
            # Always allow deselection
            self.selected = False
        elif current_selected_count < max_selections:
            # Only allow selection if under max
            self.selected = True
update_hover(mouse_x, mouse_y)

Update hover state based on mouse position.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required
Source code in notty/src/visual/base_selector.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def update_hover(self, mouse_x: int, mouse_y: int) -> None:
    """Update hover state based on mouse position.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.
    """
    if not self.enabled:
        self.hovered = False
        return
    self.hovered = (
        self.x <= mouse_x <= self.x + self.width
        and self.y <= mouse_y <= self.y + self.height
    )
SubmitButton

Represents a submit button.

Source code in notty/src/visual/cards_selector.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
class SubmitButton:
    """Represents a submit button."""

    def __init__(self, x: int, y: int, width: int, height: int) -> None:
        """Initialize a submit button.

        Args:
            x: X coordinate of the button.
            y: Y coordinate of the button.
            width: Width of the button.
            height: Height of the button.
        """
        self.x = x
        self.y = y
        self.width = width
        self.height = height
        self.hovered = False
        self.enabled = False

    def is_clicked(self, mouse_x: int, mouse_y: int) -> bool:
        """Check if the button was clicked.

        Args:
            mouse_x: Mouse x coordinate.
            mouse_y: Mouse y coordinate.

        Returns:
            True if the button was clicked and is enabled.
        """
        if not self.enabled:
            return False
        return (
            self.x <= mouse_x <= self.x + self.width
            and self.y <= mouse_y <= self.y + self.height
        )

    def update_hover(self, mouse_x: int, mouse_y: int) -> None:
        """Update hover state based on mouse position.

        Args:
            mouse_x: Mouse x coordinate.
            mouse_y: Mouse y coordinate.
        """
        self.hovered = (
            self.x <= mouse_x <= self.x + self.width
            and self.y <= mouse_y <= self.y + self.height
        )

    def draw(self, screen: pygame.Surface) -> None:
        """Draw the button.

        Args:
            screen: The pygame display surface.
        """
        # Determine button color based on state
        if not self.enabled:
            bg_color = (100, 100, 100)  # Gray for disabled
            text_color = (150, 150, 150)  # Light gray text
            border_color = (80, 80, 80)
        elif self.hovered:
            bg_color = (100, 200, 255)  # Light blue for hover
            text_color = (0, 0, 0)  # Black text
            border_color = (50, 150, 255)
        else:
            bg_color = (50, 150, 50)  # Green for enabled
            text_color = (255, 255, 255)  # White text
            border_color = (30, 100, 30)

        # Draw button background
        pygame.draw.rect(screen, bg_color, (self.x, self.y, self.width, self.height))

        # Draw button border
        pygame.draw.rect(
            screen, border_color, (self.x, self.y, self.width, self.height), 3
        )

        # Draw button text - scale font size based on button height
        font_size = int(self.height * 0.72)  # 72% of button height
        font = pygame.font.Font(None, font_size)
        text_surface = font.render("Submit", ANTI_ALIASING, text_color)
        text_rect = text_surface.get_rect(
            center=(self.x + self.width // 2, self.y + self.height // 2)
        )
        screen.blit(text_surface, text_rect)
__init__(x, y, width, height)

Initialize a submit button.

Parameters:

Name Type Description Default
x int

X coordinate of the button.

required
y int

Y coordinate of the button.

required
width int

Width of the button.

required
height int

Height of the button.

required
Source code in notty/src/visual/cards_selector.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def __init__(self, x: int, y: int, width: int, height: int) -> None:
    """Initialize a submit button.

    Args:
        x: X coordinate of the button.
        y: Y coordinate of the button.
        width: Width of the button.
        height: Height of the button.
    """
    self.x = x
    self.y = y
    self.width = width
    self.height = height
    self.hovered = False
    self.enabled = False
draw(screen)

Draw the button.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required
Source code in notty/src/visual/cards_selector.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
def draw(self, screen: pygame.Surface) -> None:
    """Draw the button.

    Args:
        screen: The pygame display surface.
    """
    # Determine button color based on state
    if not self.enabled:
        bg_color = (100, 100, 100)  # Gray for disabled
        text_color = (150, 150, 150)  # Light gray text
        border_color = (80, 80, 80)
    elif self.hovered:
        bg_color = (100, 200, 255)  # Light blue for hover
        text_color = (0, 0, 0)  # Black text
        border_color = (50, 150, 255)
    else:
        bg_color = (50, 150, 50)  # Green for enabled
        text_color = (255, 255, 255)  # White text
        border_color = (30, 100, 30)

    # Draw button background
    pygame.draw.rect(screen, bg_color, (self.x, self.y, self.width, self.height))

    # Draw button border
    pygame.draw.rect(
        screen, border_color, (self.x, self.y, self.width, self.height), 3
    )

    # Draw button text - scale font size based on button height
    font_size = int(self.height * 0.72)  # 72% of button height
    font = pygame.font.Font(None, font_size)
    text_surface = font.render("Submit", ANTI_ALIASING, text_color)
    text_rect = text_surface.get_rect(
        center=(self.x + self.width // 2, self.y + self.height // 2)
    )
    screen.blit(text_surface, text_rect)
is_clicked(mouse_x, mouse_y)

Check if the button was clicked.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required

Returns:

Type Description
bool

True if the button was clicked and is enabled.

Source code in notty/src/visual/cards_selector.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
def is_clicked(self, mouse_x: int, mouse_y: int) -> bool:
    """Check if the button was clicked.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.

    Returns:
        True if the button was clicked and is enabled.
    """
    if not self.enabled:
        return False
    return (
        self.x <= mouse_x <= self.x + self.width
        and self.y <= mouse_y <= self.y + self.height
    )
update_hover(mouse_x, mouse_y)

Update hover state based on mouse position.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required
Source code in notty/src/visual/cards_selector.py
130
131
132
133
134
135
136
137
138
139
140
def update_hover(self, mouse_x: int, mouse_y: int) -> None:
    """Update hover state based on mouse position.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.
    """
    self.hovered = (
        self.x <= mouse_x <= self.x + self.width
        and self.y <= mouse_y <= self.y + self.height
    )

deck

visual deck.

VisualDeck

Bases: Visual

Visual deck.

Source code in notty/src/visual/deck.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
class VisualDeck(Visual):
    """Visual deck."""

    NUM_DUPLICATES = 2

    def draw(self) -> None:
        """Draw the visual element.

        Args:
            screen: The pygame display surface.
        """
        for card in self.cards:
            card.draw()
        super().draw()
        # Cards stay hidden behind the deck - only draw the deck image
        # draw the number of cards in the deck in black - scale font size
        font_size = int(APP_HEIGHT * 0.12)  # 12% of screen height
        font = pygame.font.Font(None, font_size)
        text = font.render(str(self.size()), ANTI_ALIASING, (0, 0, 0))
        text_padding = int(APP_HEIGHT * 0.012)  # 1.2% of screen height
        self.screen.blit(text, (self.x + text_padding, self.y + text_padding))

    def __init__(
        self,
        screen: pygame.Surface,
    ) -> None:
        """Initialize a visual deck.

        Args:
            x: X coordinate. Always represents the top-left corner.
            y: Y coordinate. Always represents the top-left corner.
            height: Height of the visual element.
            width: Width of the visual element.
            screen: The pygame display surface.
            deck: The deck to visualize.
        """
        self.cards: list[VisualCard] = []
        super().__init__(DECK_POS_X, DECK_POS_Y, DECK_HEIGHT, DECK_WIDTH, screen)
        self._initialize_deck()

    def _initialize_deck(self) -> None:
        """Create all 90 cards (2 of each color-number combination)."""
        self.cards = []
        for color in Color.get_all_colors():
            for number in Number.get_all_numbers():
                for _ in range(self.NUM_DUPLICATES):
                    self.add_card(
                        VisualCard(color, number, DECK_POS_X, DECK_POS_Y, self.screen)
                    )

    def get_png_name(self) -> str:
        """Get the png for the visual element."""
        return "deck"

    def get_png_pkg(self) -> ModuleType:
        """Get the png for the visual element."""
        return deck

    def shuffle(self) -> None:
        """Shuffle the deck."""
        random.shuffle(self.cards)

    def draw_card(self) -> VisualCard:
        """Draw the top card from the deck.

        Returns:
            The top card, or raises ValueError if deck is empty.
        """
        if not self.cards:
            msg = "Cannot draw from an empty deck"
            raise ValueError(msg)
        return self.cards.pop()

    def draw_cards(self, count: int) -> list[VisualCard]:
        """Draw multiple cards from the deck.

        Args:
            count: Number of cards to draw.

        Returns:
            List of drawn cards (may be fewer than requested if deck runs out).
        """
        drawn_cards: list[VisualCard] = []
        for _ in range(count):
            if self.is_empty():
                break
            card = self.draw_card()
            drawn_cards.append(card)
        return drawn_cards

    def add_cards(self, cards: list[VisualCard]) -> None:
        """Add cards back to the deck (used when discarding).

        Args:
            cards: List of cards to add back to the deck.
        """
        for card in cards:
            self.add_card(card)

    def add_card(self, card: VisualCard) -> None:
        """Add a single card back to the deck (used when discarding).

        Args:
            card: VisualCard to add back to the deck.
        """
        self.cards.append(card)
        # move the card to the middle of the deck
        x, y = self.get_center()
        card.move(x, y)
        self.shuffle()

    def is_empty(self) -> bool:
        """Check if the deck is empty.

        Returns:
            True if the deck has no cards, False otherwise.
        """
        return len(self.cards) == 0

    def size(self) -> int:
        """Get the number of cards in the deck.

        Returns:
            Number of cards currently in the deck.
        """
        return len(self.cards)

    def __len__(self) -> int:
        """Return the number of cards in the deck."""
        return len(self.cards)
__init__(screen)

Initialize a visual deck.

Parameters:

Name Type Description Default
x

X coordinate. Always represents the top-left corner.

required
y

Y coordinate. Always represents the top-left corner.

required
height

Height of the visual element.

required
width

Width of the visual element.

required
screen Surface

The pygame display surface.

required
deck

The deck to visualize.

required
Source code in notty/src/visual/deck.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def __init__(
    self,
    screen: pygame.Surface,
) -> None:
    """Initialize a visual deck.

    Args:
        x: X coordinate. Always represents the top-left corner.
        y: Y coordinate. Always represents the top-left corner.
        height: Height of the visual element.
        width: Width of the visual element.
        screen: The pygame display surface.
        deck: The deck to visualize.
    """
    self.cards: list[VisualCard] = []
    super().__init__(DECK_POS_X, DECK_POS_Y, DECK_HEIGHT, DECK_WIDTH, screen)
    self._initialize_deck()
__len__()

Return the number of cards in the deck.

Source code in notty/src/visual/deck.py
148
149
150
def __len__(self) -> int:
    """Return the number of cards in the deck."""
    return len(self.cards)
_initialize_deck()

Create all 90 cards (2 of each color-number combination).

Source code in notty/src/visual/deck.py
61
62
63
64
65
66
67
68
69
def _initialize_deck(self) -> None:
    """Create all 90 cards (2 of each color-number combination)."""
    self.cards = []
    for color in Color.get_all_colors():
        for number in Number.get_all_numbers():
            for _ in range(self.NUM_DUPLICATES):
                self.add_card(
                    VisualCard(color, number, DECK_POS_X, DECK_POS_Y, self.screen)
                )
add_card(card)

Add a single card back to the deck (used when discarding).

Parameters:

Name Type Description Default
card VisualCard

VisualCard to add back to the deck.

required
Source code in notty/src/visual/deck.py
120
121
122
123
124
125
126
127
128
129
130
def add_card(self, card: VisualCard) -> None:
    """Add a single card back to the deck (used when discarding).

    Args:
        card: VisualCard to add back to the deck.
    """
    self.cards.append(card)
    # move the card to the middle of the deck
    x, y = self.get_center()
    card.move(x, y)
    self.shuffle()
add_cards(cards)

Add cards back to the deck (used when discarding).

Parameters:

Name Type Description Default
cards list[VisualCard]

List of cards to add back to the deck.

required
Source code in notty/src/visual/deck.py
111
112
113
114
115
116
117
118
def add_cards(self, cards: list[VisualCard]) -> None:
    """Add cards back to the deck (used when discarding).

    Args:
        cards: List of cards to add back to the deck.
    """
    for card in cards:
        self.add_card(card)
draw()

Draw the visual element.

Parameters:

Name Type Description Default
screen

The pygame display surface.

required
Source code in notty/src/visual/deck.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def draw(self) -> None:
    """Draw the visual element.

    Args:
        screen: The pygame display surface.
    """
    for card in self.cards:
        card.draw()
    super().draw()
    # Cards stay hidden behind the deck - only draw the deck image
    # draw the number of cards in the deck in black - scale font size
    font_size = int(APP_HEIGHT * 0.12)  # 12% of screen height
    font = pygame.font.Font(None, font_size)
    text = font.render(str(self.size()), ANTI_ALIASING, (0, 0, 0))
    text_padding = int(APP_HEIGHT * 0.012)  # 1.2% of screen height
    self.screen.blit(text, (self.x + text_padding, self.y + text_padding))
draw_card()

Draw the top card from the deck.

Returns:

Type Description
VisualCard

The top card, or raises ValueError if deck is empty.

Source code in notty/src/visual/deck.py
83
84
85
86
87
88
89
90
91
92
def draw_card(self) -> VisualCard:
    """Draw the top card from the deck.

    Returns:
        The top card, or raises ValueError if deck is empty.
    """
    if not self.cards:
        msg = "Cannot draw from an empty deck"
        raise ValueError(msg)
    return self.cards.pop()
draw_cards(count)

Draw multiple cards from the deck.

Parameters:

Name Type Description Default
count int

Number of cards to draw.

required

Returns:

Type Description
list[VisualCard]

List of drawn cards (may be fewer than requested if deck runs out).

Source code in notty/src/visual/deck.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def draw_cards(self, count: int) -> list[VisualCard]:
    """Draw multiple cards from the deck.

    Args:
        count: Number of cards to draw.

    Returns:
        List of drawn cards (may be fewer than requested if deck runs out).
    """
    drawn_cards: list[VisualCard] = []
    for _ in range(count):
        if self.is_empty():
            break
        card = self.draw_card()
        drawn_cards.append(card)
    return drawn_cards
get_center()

Get the center of the visual element.

Source code in notty/src/visual/base.py
44
45
46
def get_center(self) -> tuple[int, int]:
    """Get the center of the visual element."""
    return self.x + self.width // 2, self.y + self.height // 2
get_png_name()

Get the png for the visual element.

Source code in notty/src/visual/deck.py
71
72
73
def get_png_name(self) -> str:
    """Get the png for the visual element."""
    return "deck"
get_png_path()

Get the png for the visual element.

Source code in notty/src/visual/base.py
93
94
95
def get_png_path(self) -> Path:
    """Get the png for the visual element."""
    return resource_path(self.get_png_name() + ".png", self.get_png_pkg())
get_png_pkg()

Get the png for the visual element.

Source code in notty/src/visual/deck.py
75
76
77
def get_png_pkg(self) -> ModuleType:
    """Get the png for the visual element."""
    return deck
is_empty()

Check if the deck is empty.

Returns:

Type Description
bool

True if the deck has no cards, False otherwise.

Source code in notty/src/visual/deck.py
132
133
134
135
136
137
138
def is_empty(self) -> bool:
    """Check if the deck is empty.

    Returns:
        True if the deck has no cards, False otherwise.
    """
    return len(self.cards) == 0
move(x, y)

Move the visual element.

Animates the movement in a straight line to that given location.

Parameters:

Name Type Description Default
x int

X coordinate.

required
y int

Y coordinate.

required
Source code in notty/src/visual/base.py
48
49
50
51
52
53
54
55
56
57
58
def move(self, x: int, y: int) -> None:
    """Move the visual element.

    Animates the movement in a straight line to that given location.

    Args:
        x: X coordinate.
        y: Y coordinate.
    """
    self.target_x = x
    self.target_y = y
set_position(x, y)

Set the position of the visual element.

Parameters:

Name Type Description Default
x int

X coordinate.

required
y int

Y coordinate.

required
Source code in notty/src/visual/base.py
60
61
62
63
64
65
66
67
68
def set_position(self, x: int, y: int) -> None:
    """Set the position of the visual element.

    Args:
        x: X coordinate.
        y: Y coordinate.
    """
    self.x = x
    self.y = y
shuffle()

Shuffle the deck.

Source code in notty/src/visual/deck.py
79
80
81
def shuffle(self) -> None:
    """Shuffle the deck."""
    random.shuffle(self.cards)
size()

Get the number of cards in the deck.

Returns:

Type Description
int

Number of cards currently in the deck.

Source code in notty/src/visual/deck.py
140
141
142
143
144
145
146
def size(self) -> int:
    """Get the number of cards in the deck.

    Returns:
        Number of cards currently in the deck.
    """
    return len(self.cards)

game

visual game.

VisualGame

Bases: Visual

visual game class.

Source code in notty/src/visual/game.py
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
class VisualGame(Visual):
    """visual game class."""

    def __init__(
        self,
        screen: pygame.Surface,
        players: list[VisualPlayer],
    ) -> None:
        """Initialize a visual game.

        Args:
            players: List of players.
            screen: The pygame display surface.
        """
        super().__init__(0, 0, APP_HEIGHT, APP_WIDTH, screen)
        self.num_players = len(players)
        if not MIN_PLAYERS <= self.num_players <= MAX_PLAYERS:
            msg = f"""
            Game requires {MIN_PLAYERS}-{MAX_PLAYERS} players,
            got {self.num_players}
"""
            raise ValueError(msg)

        self.players = players
        self.deck = VisualDeck(screen=self.screen)
        self.current_player_index = 0
        self.winner: VisualPlayer | None = None

        # Track which actions have been used how many times
        self.actions_used: dict[str, int] = dict.fromkeys(Action.get_all_actions(), 0)

        # Create action board for human player
        self.action_board = self._create_action_board()

        # Track last computer action time (in milliseconds)
        self.last_computer_action_time = 0
        self.computer_action_delay = 1000  # 1 second in milliseconds

        self.setup()

    def draw(self) -> None:
        """Draw the game."""
        super().draw()
        self.deck.draw()
        for player in self.players:
            player.draw()
        self.draw_current_player_border()
        self.action_board.draw()

    def draw_current_player_border(self) -> None:
        """Draw a black rectangle around the current player's hand."""
        current_player = self.get_current_player()
        hand = current_player.hand

        # Draw a black rectangle border around the hand
        border_width = 5
        border_padding = 10

        pygame.draw.rect(
            self.screen,
            (0, 0, 0),  # Black color
            (
                hand.x - border_padding,
                hand.y - border_padding,
                hand.width + 2 * border_padding,
                hand.height + 2 * border_padding,
            ),
            border_width,
        )

    def get_png_name(self) -> str:
        """Get the png for the visual element."""
        return "icon"

    def get_png_pkg(self) -> ModuleType:
        """Get the png for the visual element."""
        return resources

    def setup(self) -> None:
        """Set up the game by shuffling deck and dealing initial cards."""
        # Shuffle deck before dealing
        self.deck.shuffle()

    def _create_action_board(self) -> "ActionBoard":
        """Create action board for the human player.

        Returns:
            ActionBoard instance if there's a human player, None otherwise.
        """
        # Find the human player index
        for i, player in enumerate(self.players):
            if player.is_human:
                return ActionBoard(self.screen, i, self)

        msg = "No human player found"
        raise ValueError(msg)

    def get_current_player(self) -> VisualPlayer:
        """Get the current player whose turn it is.

        Returns:
            The current player.
        """
        return self.players[self.current_player_index]

    def can_computer_act(self) -> bool:
        """Check if enough time has passed for computer to take an action.

        Returns:
            True if computer can act (1 second has passed since last action).
        """
        current_time = pygame.time.get_ticks()
        return (
            current_time - self.last_computer_action_time >= self.computer_action_delay
        )

    def mark_computer_action(self) -> None:
        """Mark that the computer has taken an action."""
        self.last_computer_action_time = pygame.time.get_ticks()

    def get_other_players(self) -> list[VisualPlayer]:
        """Get all players except the current player.

        Returns:
            List of other players.
        """
        return [p for i, p in enumerate(self.players) if i != self.current_player_index]

    def get_next_player(self) -> VisualPlayer:
        """Get the next player.

        Returns:
            The next player.
        """
        return self.players[(self.current_player_index + 1) % len(self.players)]

    def get_next_player_index(self) -> int:
        """Get the index of the next player.

        Returns:
            The index of the next player.
        """
        return (self.current_player_index + 1) % len(self.players)

    def next_turn(self) -> None:
        """Move to the next player's turn."""
        self.current_player_index = self.get_next_player_index()

        # Reset action tracking for new turn
        self.actions_used = dict.fromkeys(Action.get_all_actions(), False)

        # check all player shave not more than MAX_HAND_SIZE cards
        for player in self.players:
            if player.hand.size() > MAX_HAND_SIZE:
                msg = "Hand size exceeded"
                raise ValueError(msg)
        # check total number of cards is 90
        total_cards = (
            sum(player.hand.size() for player in self.players) + self.deck.size()
        )
        if total_cards != TOTAL_CARDS:
            msg = "Total number of cards is not 90"
            raise ValueError(msg)

    def check_win_condition(self) -> bool:
        """Check if any player has won (empty hand).

        Returns:
            True if game is over, False otherwise.
        """
        for player in self.players:
            if player.hand.is_empty():
                self.winner = player
                return True
        return False

    def calculate_reward(self, player_index: int, action: str) -> float:
        """Calculate reward for Q-learning agent.

        Args:
            player_index: Index of the player who took the action.
            action: The action that was taken.

        Returns:
            Reward value for the action.
        """
        player = self.players[player_index]

        # Big reward for winning
        if player.hand.is_empty():
            return 100.0

        # Reward for discarding cards (reduces hand size)
        if action == Action.DISCARD_GROUP:
            return 10.0

        # Small penalty for drawing cards (increases hand size)
        if action in [Action.DRAW_MULTIPLE, Action.DRAW_DISCARD_DRAW]:
            return -0.5

        # Neutral for steal (takes from opponent)
        if action == Action.STEAL:
            return 0.5

        # Small penalty for passing (not making progress)
        if action == Action.NEXT_TURN:
            return -1.0

        # Default small penalty to encourage action
        return -0.1

    def player_can_pass(self) -> bool:
        """Check if current player can pass.

        Returns:
            True if action is available.
        """
        # player can always pass
        return True

    def player_passes(self) -> bool:
        """VisualPlayer passes.

        Returns:
            True if action was successful.
        """
        if not self.player_can_pass():
            msg = "Cannot pass"
            raise ValueError(msg)

        self.next_turn()
        return True

    def play_for_me(self) -> bool:
        """Let the computer play for the human player.

        Returns:
            True if action was successful.
        """
        current_player = self.get_current_player()
        # make current player a computer player
        current_player.is_human = False
        # play for the computer player
        computer_chooses_action(self)
        # make current player a human player
        current_player.is_human = True
        return True

    def player_can_draw_multiple(self) -> bool:
        """Check if current player can draw cards.

        Returns:
            True if action is available.
        """
        return (
            self.actions_used[Action.DRAW_MULTIPLE] < 1
            and not self.deck.is_empty()
            and not self.get_current_player().hand.hand_is_full()
        )

    def player_draws_multiple(self, count: int | None = None) -> bool:
        """VisualPlayer draws cards.

        Args:
            count: Number of cards to draw.

        Returns:
            True if action was successful.
        """
        if not self.player_can_draw_multiple():
            msg = "Cannot draw cards"
            raise ValueError(msg)

        if count is None:
            count = self.request_number_from_player()

        cards = self.deck.draw_cards(count)
        self.get_current_player().hand.add_cards(cards)
        self.actions_used[Action.DRAW_MULTIPLE] += 1
        return True

    def request_number_from_player(self) -> int:
        """Request a number from the player.

        Returns:
            The number requested.
        """
        current_player = self.get_current_player()
        if current_player.is_human:
            # Calculate how many cards can fit in the hand
            available_space = MAX_HAND_SIZE - current_player.hand.size()
            # Also limit by deck size
            max_drawable = min(available_space, self.deck.size(), 3)
            # Show number selector dialog for human player
            selector = NumberSelector(self.screen, max_number=max_drawable)
            return cast("int", (selector.show()))
        # For computer players, choose randomly
        msg = "Should not be reached"
        raise ValueError(msg)

    def player_can_steal(self) -> bool:
        """Check if current player can steal a card.

        Returns:
            True if action is available.
        """
        return (
            self.actions_used[Action.STEAL] < 1
            and any(not p.hand.is_empty() for p in self.get_other_players())
            and not self.get_current_player().hand.hand_is_full()
        )

    def player_steals(self, target_player: VisualPlayer | None = None) -> bool:
        """VisualPlayer steals a card from another player.

        Args:
            target_player: VisualPlayer to steal from.

        Returns:
            True if action was successful.
        """
        if not self.player_can_steal():
            msg = "Cannot steal card"
            raise ValueError(msg)

        if target_player is None:
            target_player = self.request_player_from_player()

        current_player = self.get_current_player()
        # target player shuffles their hand before giving up a card
        target_player.hand.shuffle()
        card = target_player.hand.cards.pop()
        current_player.hand.add_card(card)
        self.actions_used[Action.STEAL] += 1
        return True

    def request_player_from_player(self) -> VisualPlayer:
        """Request a player from the player.

        Returns:
            The player requested.
        """
        current_player = self.get_current_player()
        if current_player.is_human:
            # Show player selector dialog for human player

            other_players = self.get_other_players()
            selector = PlayerSelector(self.screen, other_players)
            return cast("VisualPlayer", (selector.show()))
        msg = "Should not be reached"
        raise ValueError(msg)

    def player_can_draw_discard_draw(self) -> bool:
        """Check if current player can draw and discard a card.

        Returns:
            True if action is available.
        """
        #  can draw even with full hand bc discard must happen after
        return (
            self.actions_used[Action.DRAW_DISCARD_DRAW] < 1 and not self.deck.is_empty()
        )

    def player_draw_discard_draws(self) -> bool:
        """VisualPlayer draws one card and discards another.

        Returns:
            True if action was successful.
        """
        if not self.player_can_draw_discard_draw():
            msg = "Cannot do draw in action draw and discard"
            raise ValueError(msg)

        current_player = self.get_current_player()
        card = self.deck.draw_card()
        current_player.hand.add_card(card, draw_discard_draw=True)
        self.actions_used[Action.DRAW_DISCARD_DRAW] += 1
        return True

    def player_can_draw_discard_discard(self) -> bool:
        """Check if current player can draw and discard two cards.

        Returns:
            True if action is available.
        """
        return (
            self.actions_used[Action.DRAW_DISCARD_DISCARD] < 1
            and self.actions_used[Action.DRAW_DISCARD_DRAW] == 1
        )

    def player_draw_discard_discards(self, card: VisualCard | None = None) -> bool:
        """VisualPlayer discards two cards.

        Returns:
            True if action was successful.
        """
        if not self.player_can_draw_discard_discard():
            msg = "Cannot do discard in action draw and discard"
            raise ValueError(msg)

        if card is None:
            card = self.request_card_from_player()

        current_player = self.get_current_player()
        current_player.hand.remove_card(card)
        self.deck.add_card(card)
        self.actions_used[Action.DRAW_DISCARD_DISCARD] += 1
        return True

    def request_card_from_player(self) -> VisualCard:
        """Request a card from the player.

        Returns:
            The card requested.
        """
        current_player = self.get_current_player()
        if current_player.is_human:
            # Show card selector dialog for human player

            available_cards = current_player.hand.cards
            selector = CardSelector(self.screen, available_cards)
            return cast("VisualCard", (selector.show()))
        msg = "Should not be reached"
        raise ValueError(msg)

    def card_group_is_valid(self, cards: list[VisualCard]) -> bool:
        """Check if a group of cards is valid.

        Args:
            cards: List of cards to check.

        Returns:
            True if group is valid.
        """
        is_valid = False

        # order cards by number
        cards.sort(key=lambda card: card.number)

        numbers = [card.number for card in cards]
        colors = [card.color for card in cards]
        one_color = len(set(colors)) == 1
        unique_colors = len(set(colors)) == len(colors)
        consecutive_numbers = all(b - a == 1 for a, b in itertools.pairwise(numbers))
        one_number = len(set(numbers)) == 1

        # A sequence of at least three cards of the same colour
        # with consecutive numbers (e.g. blue 4, blue 5 and blue 6)
        min_cards = 3
        if len(cards) >= min_cards and one_color and consecutive_numbers:
            is_valid = True

        # A set of at least four cards of the same number
        # but different colours (e.g. blue 4, green 4 and red 4).
        # Note that no repeated colours are allowed in this type of group
        # (e.g. blue 4, red 4 and blue 4 is not a valid group)
        min_cards = 4
        if len(cards) >= min_cards and unique_colors and one_number:
            is_valid = True

        return is_valid

    def can_discard_group(self) -> bool:
        """Check if current player can discard a group of cards.

        Returns:
            True if action is available.
        """
        # go through all card combinations and check if any are valid
        for i in range(3, 5):
            for cards in itertools.combinations(
                self.get_current_player().hand.cards, i
            ):
                if self.card_group_is_valid(list(cards)):
                    return True
        return False

    def get_discardable_groups(self) -> list[list[VisualCard]]:
        """Get all discardable groups.

        Returns:
            List of discardable groups.
        """
        current_player = self.get_current_player()
        cards = current_player.hand.cards
        discardable_groups: list[list[VisualCard]] = []
        for i in range(3, len(cards) + 1):
            for cards_ in itertools.combinations(cards, i):
                if self.card_group_is_valid(list(cards_)):
                    discardable_groups.append(list(cards_))  # noqa: PERF401

        return discardable_groups

    def player_discards_group(self, cards: list[VisualCard] | None = None) -> bool:
        """VisualPlayer discards a group of cards.

        Args:
            cards: List of cards to discard.

        Returns:
            True if action was successful.
        """
        if cards is None:
            cards = self.request_cards_from_player()

        if not self.card_group_is_valid(cards):
            return False

        current_player = self.get_current_player()
        current_player.hand.remove_cards(cards)
        self.deck.add_cards(cards)
        return True

    def request_cards_from_player(self) -> list[VisualCard]:
        """Request cards from the player.

        Returns:
            The cards requested.
        """
        current_player = self.get_current_player()
        if current_player.is_human:
            # Show cards selector dialog for human player

            available_cards = current_player.hand.cards
            # Pass the validation function to check if selected cards form a valid group
            selector = CardsSelector(
                self.screen, available_cards, self.card_group_is_valid
            )
            return selector.show()
        msg = "Should not be reached"
        raise ValueError(msg)

    @classmethod
    def distribute_starting_cards(cls, game: "VisualGame") -> None:
        """Distribute starting cards to players."""
        for player in game.players:
            cards = game.deck.draw_cards(INITIAL_HAND_SIZE)
            player.hand.add_cards(cards)

    def all_players_have_no_cards(self) -> bool:
        """Check if all players have no cards."""
        return all(player.hand.is_empty() for player in self.players)

    def action_is_possible(self, action: str) -> bool:  # noqa: PLR0911
        """Check if an action is possible.

        Args:
            action: The action to check.

        Returns:
            True if action is possible.
        """
        # "Play for Me" is handled separately in the action board
        if action == Action.PLAY_FOR_ME:
            return True
        # make any button False except Draw discard discard
        # if Draw discard draw is 1 and Draw discard discard is 0
        if (
            self.actions_used[Action.DRAW_DISCARD_DRAW] == 1
            and self.actions_used[Action.DRAW_DISCARD_DISCARD] == 0
        ):
            return action == Action.DRAW_DISCARD_DISCARD
        if action == Action.DRAW_MULTIPLE:
            return self.player_can_draw_multiple()
        if action == Action.STEAL:
            return self.player_can_steal()
        if action == Action.DRAW_DISCARD_DRAW:
            return self.player_can_draw_discard_draw()
        if action == Action.DRAW_DISCARD_DISCARD:
            return self.player_can_draw_discard_discard()
        if action == Action.DISCARD_GROUP:
            return self.can_discard_group()
        if action == Action.NEXT_TURN:
            return self.player_can_pass()
        msg = f"Unknown action: {action}"
        raise ValueError(msg)

    def get_all_possible_actions(self) -> list[str]:
        """Get all possible actions.

        Returns:
            List of possible actions.
        """
        return [
            action
            for action in Action.get_all_actions()
            if self.action_is_possible(action)
        ]

    def do_action(  # noqa: PLR0911
        self,
        action: str,
        count: int | None = None,
        card: VisualCard | None = None,
        cards: list[VisualCard] | None = None,
        target_player: VisualPlayer | None = None,
    ) -> bool:
        """Do an action.

        Args:
            action: The action to do.
            count: Number of cards to draw (for draw multiple action).
            card: Card to discard (for draw discard discard action).
            cards: Cards to discard (for discard group action).
            target_player: Player to steal from (for steal action).

        Returns:
            True if action was successful.
        """
        if action == Action.PLAY_FOR_ME:
            return self.play_for_me()
        if action == Action.DRAW_MULTIPLE:
            return self.player_draws_multiple(count=count)
        if action == Action.STEAL:
            return self.player_steals(target_player=target_player)
        if action == Action.DRAW_DISCARD_DRAW:
            return self.player_draw_discard_draws()
        if action == Action.DRAW_DISCARD_DISCARD:
            return self.player_draw_discard_discards(card=card)
        if action == Action.DISCARD_GROUP:
            return self.player_discards_group(cards=cards)
        if action == Action.NEXT_TURN:
            return self.player_passes()
        msg = f"Unknown action: {action}"
        raise ValueError(msg)
__init__(screen, players)

Initialize a visual game.

Parameters:

Name Type Description Default
players list[VisualPlayer]

List of players.

required
screen Surface

The pygame display surface.

required
Source code in notty/src/visual/game.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
    def __init__(
        self,
        screen: pygame.Surface,
        players: list[VisualPlayer],
    ) -> None:
        """Initialize a visual game.

        Args:
            players: List of players.
            screen: The pygame display surface.
        """
        super().__init__(0, 0, APP_HEIGHT, APP_WIDTH, screen)
        self.num_players = len(players)
        if not MIN_PLAYERS <= self.num_players <= MAX_PLAYERS:
            msg = f"""
            Game requires {MIN_PLAYERS}-{MAX_PLAYERS} players,
            got {self.num_players}
"""
            raise ValueError(msg)

        self.players = players
        self.deck = VisualDeck(screen=self.screen)
        self.current_player_index = 0
        self.winner: VisualPlayer | None = None

        # Track which actions have been used how many times
        self.actions_used: dict[str, int] = dict.fromkeys(Action.get_all_actions(), 0)

        # Create action board for human player
        self.action_board = self._create_action_board()

        # Track last computer action time (in milliseconds)
        self.last_computer_action_time = 0
        self.computer_action_delay = 1000  # 1 second in milliseconds

        self.setup()
_create_action_board()

Create action board for the human player.

Returns:

Type Description
ActionBoard

ActionBoard instance if there's a human player, None otherwise.

Source code in notty/src/visual/game.py
114
115
116
117
118
119
120
121
122
123
124
125
126
def _create_action_board(self) -> "ActionBoard":
    """Create action board for the human player.

    Returns:
        ActionBoard instance if there's a human player, None otherwise.
    """
    # Find the human player index
    for i, player in enumerate(self.players):
        if player.is_human:
            return ActionBoard(self.screen, i, self)

    msg = "No human player found"
    raise ValueError(msg)
action_is_possible(action)

Check if an action is possible.

Parameters:

Name Type Description Default
action str

The action to check.

required

Returns:

Type Description
bool

True if action is possible.

Source code in notty/src/visual/game.py
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
def action_is_possible(self, action: str) -> bool:  # noqa: PLR0911
    """Check if an action is possible.

    Args:
        action: The action to check.

    Returns:
        True if action is possible.
    """
    # "Play for Me" is handled separately in the action board
    if action == Action.PLAY_FOR_ME:
        return True
    # make any button False except Draw discard discard
    # if Draw discard draw is 1 and Draw discard discard is 0
    if (
        self.actions_used[Action.DRAW_DISCARD_DRAW] == 1
        and self.actions_used[Action.DRAW_DISCARD_DISCARD] == 0
    ):
        return action == Action.DRAW_DISCARD_DISCARD
    if action == Action.DRAW_MULTIPLE:
        return self.player_can_draw_multiple()
    if action == Action.STEAL:
        return self.player_can_steal()
    if action == Action.DRAW_DISCARD_DRAW:
        return self.player_can_draw_discard_draw()
    if action == Action.DRAW_DISCARD_DISCARD:
        return self.player_can_draw_discard_discard()
    if action == Action.DISCARD_GROUP:
        return self.can_discard_group()
    if action == Action.NEXT_TURN:
        return self.player_can_pass()
    msg = f"Unknown action: {action}"
    raise ValueError(msg)
all_players_have_no_cards()

Check if all players have no cards.

Source code in notty/src/visual/game.py
570
571
572
def all_players_have_no_cards(self) -> bool:
    """Check if all players have no cards."""
    return all(player.hand.is_empty() for player in self.players)
calculate_reward(player_index, action)

Calculate reward for Q-learning agent.

Parameters:

Name Type Description Default
player_index int

Index of the player who took the action.

required
action str

The action that was taken.

required

Returns:

Type Description
float

Reward value for the action.

Source code in notty/src/visual/game.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def calculate_reward(self, player_index: int, action: str) -> float:
    """Calculate reward for Q-learning agent.

    Args:
        player_index: Index of the player who took the action.
        action: The action that was taken.

    Returns:
        Reward value for the action.
    """
    player = self.players[player_index]

    # Big reward for winning
    if player.hand.is_empty():
        return 100.0

    # Reward for discarding cards (reduces hand size)
    if action == Action.DISCARD_GROUP:
        return 10.0

    # Small penalty for drawing cards (increases hand size)
    if action in [Action.DRAW_MULTIPLE, Action.DRAW_DISCARD_DRAW]:
        return -0.5

    # Neutral for steal (takes from opponent)
    if action == Action.STEAL:
        return 0.5

    # Small penalty for passing (not making progress)
    if action == Action.NEXT_TURN:
        return -1.0

    # Default small penalty to encourage action
    return -0.1
can_computer_act()

Check if enough time has passed for computer to take an action.

Returns:

Type Description
bool

True if computer can act (1 second has passed since last action).

Source code in notty/src/visual/game.py
136
137
138
139
140
141
142
143
144
145
def can_computer_act(self) -> bool:
    """Check if enough time has passed for computer to take an action.

    Returns:
        True if computer can act (1 second has passed since last action).
    """
    current_time = pygame.time.get_ticks()
    return (
        current_time - self.last_computer_action_time >= self.computer_action_delay
    )
can_discard_group()

Check if current player can discard a group of cards.

Returns:

Type Description
bool

True if action is available.

Source code in notty/src/visual/game.py
493
494
495
496
497
498
499
500
501
502
503
504
505
506
def can_discard_group(self) -> bool:
    """Check if current player can discard a group of cards.

    Returns:
        True if action is available.
    """
    # go through all card combinations and check if any are valid
    for i in range(3, 5):
        for cards in itertools.combinations(
            self.get_current_player().hand.cards, i
        ):
            if self.card_group_is_valid(list(cards)):
                return True
    return False
card_group_is_valid(cards)

Check if a group of cards is valid.

Parameters:

Name Type Description Default
cards list[VisualCard]

List of cards to check.

required

Returns:

Type Description
bool

True if group is valid.

Source code in notty/src/visual/game.py
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
def card_group_is_valid(self, cards: list[VisualCard]) -> bool:
    """Check if a group of cards is valid.

    Args:
        cards: List of cards to check.

    Returns:
        True if group is valid.
    """
    is_valid = False

    # order cards by number
    cards.sort(key=lambda card: card.number)

    numbers = [card.number for card in cards]
    colors = [card.color for card in cards]
    one_color = len(set(colors)) == 1
    unique_colors = len(set(colors)) == len(colors)
    consecutive_numbers = all(b - a == 1 for a, b in itertools.pairwise(numbers))
    one_number = len(set(numbers)) == 1

    # A sequence of at least three cards of the same colour
    # with consecutive numbers (e.g. blue 4, blue 5 and blue 6)
    min_cards = 3
    if len(cards) >= min_cards and one_color and consecutive_numbers:
        is_valid = True

    # A set of at least four cards of the same number
    # but different colours (e.g. blue 4, green 4 and red 4).
    # Note that no repeated colours are allowed in this type of group
    # (e.g. blue 4, red 4 and blue 4 is not a valid group)
    min_cards = 4
    if len(cards) >= min_cards and unique_colors and one_number:
        is_valid = True

    return is_valid
check_win_condition()

Check if any player has won (empty hand).

Returns:

Type Description
bool

True if game is over, False otherwise.

Source code in notty/src/visual/game.py
195
196
197
198
199
200
201
202
203
204
205
def check_win_condition(self) -> bool:
    """Check if any player has won (empty hand).

    Returns:
        True if game is over, False otherwise.
    """
    for player in self.players:
        if player.hand.is_empty():
            self.winner = player
            return True
    return False
distribute_starting_cards(game) classmethod

Distribute starting cards to players.

Source code in notty/src/visual/game.py
563
564
565
566
567
568
@classmethod
def distribute_starting_cards(cls, game: "VisualGame") -> None:
    """Distribute starting cards to players."""
    for player in game.players:
        cards = game.deck.draw_cards(INITIAL_HAND_SIZE)
        player.hand.add_cards(cards)
do_action(action, count=None, card=None, cards=None, target_player=None)

Do an action.

Parameters:

Name Type Description Default
action str

The action to do.

required
count int | None

Number of cards to draw (for draw multiple action).

None
card VisualCard | None

Card to discard (for draw discard discard action).

None
cards list[VisualCard] | None

Cards to discard (for discard group action).

None
target_player VisualPlayer | None

Player to steal from (for steal action).

None

Returns:

Type Description
bool

True if action was successful.

Source code in notty/src/visual/game.py
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
def do_action(  # noqa: PLR0911
    self,
    action: str,
    count: int | None = None,
    card: VisualCard | None = None,
    cards: list[VisualCard] | None = None,
    target_player: VisualPlayer | None = None,
) -> bool:
    """Do an action.

    Args:
        action: The action to do.
        count: Number of cards to draw (for draw multiple action).
        card: Card to discard (for draw discard discard action).
        cards: Cards to discard (for discard group action).
        target_player: Player to steal from (for steal action).

    Returns:
        True if action was successful.
    """
    if action == Action.PLAY_FOR_ME:
        return self.play_for_me()
    if action == Action.DRAW_MULTIPLE:
        return self.player_draws_multiple(count=count)
    if action == Action.STEAL:
        return self.player_steals(target_player=target_player)
    if action == Action.DRAW_DISCARD_DRAW:
        return self.player_draw_discard_draws()
    if action == Action.DRAW_DISCARD_DISCARD:
        return self.player_draw_discard_discards(card=card)
    if action == Action.DISCARD_GROUP:
        return self.player_discards_group(cards=cards)
    if action == Action.NEXT_TURN:
        return self.player_passes()
    msg = f"Unknown action: {action}"
    raise ValueError(msg)
draw()

Draw the game.

Source code in notty/src/visual/game.py
71
72
73
74
75
76
77
78
def draw(self) -> None:
    """Draw the game."""
    super().draw()
    self.deck.draw()
    for player in self.players:
        player.draw()
    self.draw_current_player_border()
    self.action_board.draw()
draw_current_player_border()

Draw a black rectangle around the current player's hand.

Source code in notty/src/visual/game.py
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def draw_current_player_border(self) -> None:
    """Draw a black rectangle around the current player's hand."""
    current_player = self.get_current_player()
    hand = current_player.hand

    # Draw a black rectangle border around the hand
    border_width = 5
    border_padding = 10

    pygame.draw.rect(
        self.screen,
        (0, 0, 0),  # Black color
        (
            hand.x - border_padding,
            hand.y - border_padding,
            hand.width + 2 * border_padding,
            hand.height + 2 * border_padding,
        ),
        border_width,
    )
get_all_possible_actions()

Get all possible actions.

Returns:

Type Description
list[str]

List of possible actions.

Source code in notty/src/visual/game.py
608
609
610
611
612
613
614
615
616
617
618
def get_all_possible_actions(self) -> list[str]:
    """Get all possible actions.

    Returns:
        List of possible actions.
    """
    return [
        action
        for action in Action.get_all_actions()
        if self.action_is_possible(action)
    ]
get_center()

Get the center of the visual element.

Source code in notty/src/visual/base.py
44
45
46
def get_center(self) -> tuple[int, int]:
    """Get the center of the visual element."""
    return self.x + self.width // 2, self.y + self.height // 2
get_current_player()

Get the current player whose turn it is.

Returns:

Type Description
VisualPlayer

The current player.

Source code in notty/src/visual/game.py
128
129
130
131
132
133
134
def get_current_player(self) -> VisualPlayer:
    """Get the current player whose turn it is.

    Returns:
        The current player.
    """
    return self.players[self.current_player_index]
get_discardable_groups()

Get all discardable groups.

Returns:

Type Description
list[list[VisualCard]]

List of discardable groups.

Source code in notty/src/visual/game.py
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
def get_discardable_groups(self) -> list[list[VisualCard]]:
    """Get all discardable groups.

    Returns:
        List of discardable groups.
    """
    current_player = self.get_current_player()
    cards = current_player.hand.cards
    discardable_groups: list[list[VisualCard]] = []
    for i in range(3, len(cards) + 1):
        for cards_ in itertools.combinations(cards, i):
            if self.card_group_is_valid(list(cards_)):
                discardable_groups.append(list(cards_))  # noqa: PERF401

    return discardable_groups
get_next_player()

Get the next player.

Returns:

Type Description
VisualPlayer

The next player.

Source code in notty/src/visual/game.py
159
160
161
162
163
164
165
def get_next_player(self) -> VisualPlayer:
    """Get the next player.

    Returns:
        The next player.
    """
    return self.players[(self.current_player_index + 1) % len(self.players)]
get_next_player_index()

Get the index of the next player.

Returns:

Type Description
int

The index of the next player.

Source code in notty/src/visual/game.py
167
168
169
170
171
172
173
def get_next_player_index(self) -> int:
    """Get the index of the next player.

    Returns:
        The index of the next player.
    """
    return (self.current_player_index + 1) % len(self.players)
get_other_players()

Get all players except the current player.

Returns:

Type Description
list[VisualPlayer]

List of other players.

Source code in notty/src/visual/game.py
151
152
153
154
155
156
157
def get_other_players(self) -> list[VisualPlayer]:
    """Get all players except the current player.

    Returns:
        List of other players.
    """
    return [p for i, p in enumerate(self.players) if i != self.current_player_index]
get_png_name()

Get the png for the visual element.

Source code in notty/src/visual/game.py
101
102
103
def get_png_name(self) -> str:
    """Get the png for the visual element."""
    return "icon"
get_png_path()

Get the png for the visual element.

Source code in notty/src/visual/base.py
93
94
95
def get_png_path(self) -> Path:
    """Get the png for the visual element."""
    return resource_path(self.get_png_name() + ".png", self.get_png_pkg())
get_png_pkg()

Get the png for the visual element.

Source code in notty/src/visual/game.py
105
106
107
def get_png_pkg(self) -> ModuleType:
    """Get the png for the visual element."""
    return resources
mark_computer_action()

Mark that the computer has taken an action.

Source code in notty/src/visual/game.py
147
148
149
def mark_computer_action(self) -> None:
    """Mark that the computer has taken an action."""
    self.last_computer_action_time = pygame.time.get_ticks()
move(x, y)

Move the visual element.

Animates the movement in a straight line to that given location.

Parameters:

Name Type Description Default
x int

X coordinate.

required
y int

Y coordinate.

required
Source code in notty/src/visual/base.py
48
49
50
51
52
53
54
55
56
57
58
def move(self, x: int, y: int) -> None:
    """Move the visual element.

    Animates the movement in a straight line to that given location.

    Args:
        x: X coordinate.
        y: Y coordinate.
    """
    self.target_x = x
    self.target_y = y
next_turn()

Move to the next player's turn.

Source code in notty/src/visual/game.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def next_turn(self) -> None:
    """Move to the next player's turn."""
    self.current_player_index = self.get_next_player_index()

    # Reset action tracking for new turn
    self.actions_used = dict.fromkeys(Action.get_all_actions(), False)

    # check all player shave not more than MAX_HAND_SIZE cards
    for player in self.players:
        if player.hand.size() > MAX_HAND_SIZE:
            msg = "Hand size exceeded"
            raise ValueError(msg)
    # check total number of cards is 90
    total_cards = (
        sum(player.hand.size() for player in self.players) + self.deck.size()
    )
    if total_cards != TOTAL_CARDS:
        msg = "Total number of cards is not 90"
        raise ValueError(msg)
play_for_me()

Let the computer play for the human player.

Returns:

Type Description
bool

True if action was successful.

Source code in notty/src/visual/game.py
264
265
266
267
268
269
270
271
272
273
274
275
276
277
def play_for_me(self) -> bool:
    """Let the computer play for the human player.

    Returns:
        True if action was successful.
    """
    current_player = self.get_current_player()
    # make current player a computer player
    current_player.is_human = False
    # play for the computer player
    computer_chooses_action(self)
    # make current player a human player
    current_player.is_human = True
    return True
player_can_draw_discard_discard()

Check if current player can draw and discard two cards.

Returns:

Type Description
bool

True if action is available.

Source code in notty/src/visual/game.py
410
411
412
413
414
415
416
417
418
419
def player_can_draw_discard_discard(self) -> bool:
    """Check if current player can draw and discard two cards.

    Returns:
        True if action is available.
    """
    return (
        self.actions_used[Action.DRAW_DISCARD_DISCARD] < 1
        and self.actions_used[Action.DRAW_DISCARD_DRAW] == 1
    )
player_can_draw_discard_draw()

Check if current player can draw and discard a card.

Returns:

Type Description
bool

True if action is available.

Source code in notty/src/visual/game.py
383
384
385
386
387
388
389
390
391
392
def player_can_draw_discard_draw(self) -> bool:
    """Check if current player can draw and discard a card.

    Returns:
        True if action is available.
    """
    #  can draw even with full hand bc discard must happen after
    return (
        self.actions_used[Action.DRAW_DISCARD_DRAW] < 1 and not self.deck.is_empty()
    )
player_can_draw_multiple()

Check if current player can draw cards.

Returns:

Type Description
bool

True if action is available.

Source code in notty/src/visual/game.py
279
280
281
282
283
284
285
286
287
288
289
def player_can_draw_multiple(self) -> bool:
    """Check if current player can draw cards.

    Returns:
        True if action is available.
    """
    return (
        self.actions_used[Action.DRAW_MULTIPLE] < 1
        and not self.deck.is_empty()
        and not self.get_current_player().hand.hand_is_full()
    )
player_can_pass()

Check if current player can pass.

Returns:

Type Description
bool

True if action is available.

Source code in notty/src/visual/game.py
242
243
244
245
246
247
248
249
def player_can_pass(self) -> bool:
    """Check if current player can pass.

    Returns:
        True if action is available.
    """
    # player can always pass
    return True
player_can_steal()

Check if current player can steal a card.

Returns:

Type Description
bool

True if action is available.

Source code in notty/src/visual/game.py
331
332
333
334
335
336
337
338
339
340
341
def player_can_steal(self) -> bool:
    """Check if current player can steal a card.

    Returns:
        True if action is available.
    """
    return (
        self.actions_used[Action.STEAL] < 1
        and any(not p.hand.is_empty() for p in self.get_other_players())
        and not self.get_current_player().hand.hand_is_full()
    )
player_discards_group(cards=None)

VisualPlayer discards a group of cards.

Parameters:

Name Type Description Default
cards list[VisualCard] | None

List of cards to discard.

None

Returns:

Type Description
bool

True if action was successful.

Source code in notty/src/visual/game.py
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
def player_discards_group(self, cards: list[VisualCard] | None = None) -> bool:
    """VisualPlayer discards a group of cards.

    Args:
        cards: List of cards to discard.

    Returns:
        True if action was successful.
    """
    if cards is None:
        cards = self.request_cards_from_player()

    if not self.card_group_is_valid(cards):
        return False

    current_player = self.get_current_player()
    current_player.hand.remove_cards(cards)
    self.deck.add_cards(cards)
    return True
player_draw_discard_discards(card=None)

VisualPlayer discards two cards.

Returns:

Type Description
bool

True if action was successful.

Source code in notty/src/visual/game.py
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
def player_draw_discard_discards(self, card: VisualCard | None = None) -> bool:
    """VisualPlayer discards two cards.

    Returns:
        True if action was successful.
    """
    if not self.player_can_draw_discard_discard():
        msg = "Cannot do discard in action draw and discard"
        raise ValueError(msg)

    if card is None:
        card = self.request_card_from_player()

    current_player = self.get_current_player()
    current_player.hand.remove_card(card)
    self.deck.add_card(card)
    self.actions_used[Action.DRAW_DISCARD_DISCARD] += 1
    return True
player_draw_discard_draws()

VisualPlayer draws one card and discards another.

Returns:

Type Description
bool

True if action was successful.

Source code in notty/src/visual/game.py
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
def player_draw_discard_draws(self) -> bool:
    """VisualPlayer draws one card and discards another.

    Returns:
        True if action was successful.
    """
    if not self.player_can_draw_discard_draw():
        msg = "Cannot do draw in action draw and discard"
        raise ValueError(msg)

    current_player = self.get_current_player()
    card = self.deck.draw_card()
    current_player.hand.add_card(card, draw_discard_draw=True)
    self.actions_used[Action.DRAW_DISCARD_DRAW] += 1
    return True
player_draws_multiple(count=None)

VisualPlayer draws cards.

Parameters:

Name Type Description Default
count int | None

Number of cards to draw.

None

Returns:

Type Description
bool

True if action was successful.

Source code in notty/src/visual/game.py
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
def player_draws_multiple(self, count: int | None = None) -> bool:
    """VisualPlayer draws cards.

    Args:
        count: Number of cards to draw.

    Returns:
        True if action was successful.
    """
    if not self.player_can_draw_multiple():
        msg = "Cannot draw cards"
        raise ValueError(msg)

    if count is None:
        count = self.request_number_from_player()

    cards = self.deck.draw_cards(count)
    self.get_current_player().hand.add_cards(cards)
    self.actions_used[Action.DRAW_MULTIPLE] += 1
    return True
player_passes()

VisualPlayer passes.

Returns:

Type Description
bool

True if action was successful.

Source code in notty/src/visual/game.py
251
252
253
254
255
256
257
258
259
260
261
262
def player_passes(self) -> bool:
    """VisualPlayer passes.

    Returns:
        True if action was successful.
    """
    if not self.player_can_pass():
        msg = "Cannot pass"
        raise ValueError(msg)

    self.next_turn()
    return True
player_steals(target_player=None)

VisualPlayer steals a card from another player.

Parameters:

Name Type Description Default
target_player VisualPlayer | None

VisualPlayer to steal from.

None

Returns:

Type Description
bool

True if action was successful.

Source code in notty/src/visual/game.py
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
def player_steals(self, target_player: VisualPlayer | None = None) -> bool:
    """VisualPlayer steals a card from another player.

    Args:
        target_player: VisualPlayer to steal from.

    Returns:
        True if action was successful.
    """
    if not self.player_can_steal():
        msg = "Cannot steal card"
        raise ValueError(msg)

    if target_player is None:
        target_player = self.request_player_from_player()

    current_player = self.get_current_player()
    # target player shuffles their hand before giving up a card
    target_player.hand.shuffle()
    card = target_player.hand.cards.pop()
    current_player.hand.add_card(card)
    self.actions_used[Action.STEAL] += 1
    return True
request_card_from_player()

Request a card from the player.

Returns:

Type Description
VisualCard

The card requested.

Source code in notty/src/visual/game.py
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
def request_card_from_player(self) -> VisualCard:
    """Request a card from the player.

    Returns:
        The card requested.
    """
    current_player = self.get_current_player()
    if current_player.is_human:
        # Show card selector dialog for human player

        available_cards = current_player.hand.cards
        selector = CardSelector(self.screen, available_cards)
        return cast("VisualCard", (selector.show()))
    msg = "Should not be reached"
    raise ValueError(msg)
request_cards_from_player()

Request cards from the player.

Returns:

Type Description
list[VisualCard]

The cards requested.

Source code in notty/src/visual/game.py
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
def request_cards_from_player(self) -> list[VisualCard]:
    """Request cards from the player.

    Returns:
        The cards requested.
    """
    current_player = self.get_current_player()
    if current_player.is_human:
        # Show cards selector dialog for human player

        available_cards = current_player.hand.cards
        # Pass the validation function to check if selected cards form a valid group
        selector = CardsSelector(
            self.screen, available_cards, self.card_group_is_valid
        )
        return selector.show()
    msg = "Should not be reached"
    raise ValueError(msg)
request_number_from_player()

Request a number from the player.

Returns:

Type Description
int

The number requested.

Source code in notty/src/visual/game.py
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
def request_number_from_player(self) -> int:
    """Request a number from the player.

    Returns:
        The number requested.
    """
    current_player = self.get_current_player()
    if current_player.is_human:
        # Calculate how many cards can fit in the hand
        available_space = MAX_HAND_SIZE - current_player.hand.size()
        # Also limit by deck size
        max_drawable = min(available_space, self.deck.size(), 3)
        # Show number selector dialog for human player
        selector = NumberSelector(self.screen, max_number=max_drawable)
        return cast("int", (selector.show()))
    # For computer players, choose randomly
    msg = "Should not be reached"
    raise ValueError(msg)
request_player_from_player()

Request a player from the player.

Returns:

Type Description
VisualPlayer

The player requested.

Source code in notty/src/visual/game.py
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
def request_player_from_player(self) -> VisualPlayer:
    """Request a player from the player.

    Returns:
        The player requested.
    """
    current_player = self.get_current_player()
    if current_player.is_human:
        # Show player selector dialog for human player

        other_players = self.get_other_players()
        selector = PlayerSelector(self.screen, other_players)
        return cast("VisualPlayer", (selector.show()))
    msg = "Should not be reached"
    raise ValueError(msg)
set_position(x, y)

Set the position of the visual element.

Parameters:

Name Type Description Default
x int

X coordinate.

required
y int

Y coordinate.

required
Source code in notty/src/visual/base.py
60
61
62
63
64
65
66
67
68
def set_position(self, x: int, y: int) -> None:
    """Set the position of the visual element.

    Args:
        x: X coordinate.
        y: Y coordinate.
    """
    self.x = x
    self.y = y
setup()

Set up the game by shuffling deck and dealing initial cards.

Source code in notty/src/visual/game.py
109
110
111
112
def setup(self) -> None:
    """Set up the game by shuffling deck and dealing initial cards."""
    # Shuffle deck before dealing
    self.deck.shuffle()

number_selector

Number selector dialog for choosing how many cards to draw.

NumberButton

Bases: SelectableButton[int]

Represents a clickable number button.

Source code in notty/src/visual/number_selector.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
class NumberButton(SelectableButton[int]):
    """Represents a clickable number button."""

    def __init__(  # noqa: PLR0913
        self,
        x: int,
        y: int,
        width: int,
        height: int,
        number: int,
        *,
        enabled: bool = True,
    ) -> None:
        """Initialize a number button.

        Args:
            x: X coordinate of the button.
            y: Y coordinate of the button.
            width: Width of the button.
            height: Height of the button.
            number: The number this button represents.
            enabled: Whether the button is enabled (clickable).
        """
        # NumberButton doesn't use images, so pass None
        super().__init__(
            x, y, width, height, number, None, enabled=enabled, selectable=False
        )
        self.number = number

    def draw(self, screen: pygame.Surface) -> None:
        """Draw the button.

        Args:
            screen: The pygame display surface.
        """
        # Determine button color based on state
        if not self.enabled:
            bg_color = (100, 100, 100)  # Gray for disabled
            text_color = (150, 150, 150)  # Light gray text
            border_color = (70, 70, 70)
        elif self.hovered:
            bg_color = (100, 200, 255)  # Light blue for hover
            text_color = (0, 0, 0)  # Black text
            border_color = (50, 150, 255)
        else:
            bg_color = (50, 150, 50)  # Green
            text_color = (255, 255, 255)  # White text
            border_color = (30, 100, 30)

        # Draw button background
        pygame.draw.rect(screen, bg_color, (self.x, self.y, self.width, self.height))

        # Draw button border
        pygame.draw.rect(
            screen, border_color, (self.x, self.y, self.width, self.height), 3
        )

        # Draw button text - scale font size based on button height
        font_size = int(self.height * 0.7)  # 70% of button height
        font = pygame.font.Font(None, font_size)
        text_surface = font.render(str(self.number), ANTI_ALIASING, text_color)
        text_rect = text_surface.get_rect(
            center=(self.x + self.width // 2, self.y + self.height // 2)
        )
        screen.blit(text_surface, text_rect)
__init__(x, y, width, height, number, *, enabled=True)

Initialize a number button.

Parameters:

Name Type Description Default
x int

X coordinate of the button.

required
y int

Y coordinate of the button.

required
width int

Width of the button.

required
height int

Height of the button.

required
number int

The number this button represents.

required
enabled bool

Whether the button is enabled (clickable).

True
Source code in notty/src/visual/number_selector.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def __init__(  # noqa: PLR0913
    self,
    x: int,
    y: int,
    width: int,
    height: int,
    number: int,
    *,
    enabled: bool = True,
) -> None:
    """Initialize a number button.

    Args:
        x: X coordinate of the button.
        y: Y coordinate of the button.
        width: Width of the button.
        height: Height of the button.
        number: The number this button represents.
        enabled: Whether the button is enabled (clickable).
    """
    # NumberButton doesn't use images, so pass None
    super().__init__(
        x, y, width, height, number, None, enabled=enabled, selectable=False
    )
    self.number = number
draw(screen)

Draw the button.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required
Source code in notty/src/visual/number_selector.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
def draw(self, screen: pygame.Surface) -> None:
    """Draw the button.

    Args:
        screen: The pygame display surface.
    """
    # Determine button color based on state
    if not self.enabled:
        bg_color = (100, 100, 100)  # Gray for disabled
        text_color = (150, 150, 150)  # Light gray text
        border_color = (70, 70, 70)
    elif self.hovered:
        bg_color = (100, 200, 255)  # Light blue for hover
        text_color = (0, 0, 0)  # Black text
        border_color = (50, 150, 255)
    else:
        bg_color = (50, 150, 50)  # Green
        text_color = (255, 255, 255)  # White text
        border_color = (30, 100, 30)

    # Draw button background
    pygame.draw.rect(screen, bg_color, (self.x, self.y, self.width, self.height))

    # Draw button border
    pygame.draw.rect(
        screen, border_color, (self.x, self.y, self.width, self.height), 3
    )

    # Draw button text - scale font size based on button height
    font_size = int(self.height * 0.7)  # 70% of button height
    font = pygame.font.Font(None, font_size)
    text_surface = font.render(str(self.number), ANTI_ALIASING, text_color)
    text_rect = text_surface.get_rect(
        center=(self.x + self.width // 2, self.y + self.height // 2)
    )
    screen.blit(text_surface, text_rect)
is_clicked(mouse_x, mouse_y)

Check if the button was clicked.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required

Returns:

Type Description
bool

True if the button was clicked and is enabled.

Source code in notty/src/visual/base_selector.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def is_clicked(self, mouse_x: int, mouse_y: int) -> bool:
    """Check if the button was clicked.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.

    Returns:
        True if the button was clicked and is enabled.
    """
    if not self.enabled:
        return False
    return (
        self.x <= mouse_x <= self.x + self.width
        and self.y <= mouse_y <= self.y + self.height
    )
toggle_selection(current_selected_count, max_selections)

Toggle the selection state of this button.

Parameters:

Name Type Description Default
current_selected_count int

Number of currently selected items.

required
max_selections int

Maximum number of selections allowed.

required
Source code in notty/src/visual/base_selector.py
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def toggle_selection(
    self, current_selected_count: int, max_selections: int
) -> None:
    """Toggle the selection state of this button.

    Args:
        current_selected_count: Number of currently selected items.
        max_selections: Maximum number of selections allowed.
    """
    if self.selectable:
        if self.selected:
            # Always allow deselection
            self.selected = False
        elif current_selected_count < max_selections:
            # Only allow selection if under max
            self.selected = True
update_hover(mouse_x, mouse_y)

Update hover state based on mouse position.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required
Source code in notty/src/visual/base_selector.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def update_hover(self, mouse_x: int, mouse_y: int) -> None:
    """Update hover state based on mouse position.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.
    """
    if not self.enabled:
        self.hovered = False
        return
    self.hovered = (
        self.x <= mouse_x <= self.x + self.width
        and self.y <= mouse_y <= self.y + self.height
    )
NumberSelector

Bases: BaseSelector[int]

Dialog for selecting a number (1, 2, or 3).

Source code in notty/src/visual/number_selector.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
class NumberSelector(BaseSelector[int]):
    """Dialog for selecting a number (1, 2, or 3)."""

    def __init__(self, screen: pygame.Surface, max_number: int = 3) -> None:
        """Initialize the number selector.

        Args:
            screen: The pygame display surface.
            max_number: Maximum number that can be selected (1-3).
        """
        self.max_number = min(max(1, max_number), 3)  # Clamp between 1 and 3
        # Create items list [1, 2, 3]
        items = list(range(1, 4))
        super().__init__(
            screen,
            title="How many cards do you want to draw?",
            items=items,
            max_selections=1,
        )

    def _get_button_dimensions(self) -> tuple[int, int, int]:
        """Get button dimensions (width, height, spacing).

        Returns:
            Tuple of (button_width, button_height, button_spacing).
        """
        button_width = int(APP_WIDTH * 0.08)  # 8% of screen width
        button_height = int(APP_HEIGHT * 0.12)  # 12% of screen height
        button_spacing = int(APP_WIDTH * 0.015)  # 1.5% of screen width
        return button_width, button_height, button_spacing

    def _get_dialog_dimensions(self) -> tuple[int, int]:
        """Get dialog dimensions.

        Returns:
            Tuple of (dialog_width, dialog_height).
        """
        dialog_width = int(APP_WIDTH * 0.35)  # 35% of screen width
        dialog_height = int(APP_HEIGHT * 0.30)  # 30% of screen height
        return dialog_width, dialog_height

    def _setup_buttons(self) -> None:
        """Set up the number buttons."""
        # Get button dimensions
        button_width, button_height, button_spacing = self._get_button_dimensions()

        # Center the buttons horizontally
        total_width = 3 * button_width + 2 * button_spacing
        start_x = int((APP_WIDTH - total_width) // 2)
        y = int(APP_HEIGHT // 2 - button_height // 2)

        # Create buttons for 1, 2, 3
        for i in range(3):
            number = i + 1
            x = start_x + i * (button_width + button_spacing)
            # Disable buttons that exceed max_number
            enabled = number <= self.max_number
            button = NumberButton(
                x, y, button_width, button_height, number, enabled=enabled
            )
            self.buttons.append(button)
__init__(screen, max_number=3)

Initialize the number selector.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required
max_number int

Maximum number that can be selected (1-3).

3
Source code in notty/src/visual/number_selector.py
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def __init__(self, screen: pygame.Surface, max_number: int = 3) -> None:
    """Initialize the number selector.

    Args:
        screen: The pygame display surface.
        max_number: Maximum number that can be selected (1-3).
    """
    self.max_number = min(max(1, max_number), 3)  # Clamp between 1 and 3
    # Create items list [1, 2, 3]
    items = list(range(1, 4))
    super().__init__(
        screen,
        title="How many cards do you want to draw?",
        items=items,
        max_selections=1,
    )
_draw()

Draw the selector dialog.

Source code in notty/src/visual/base_selector.py
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
def _draw(self) -> None:
    """Draw the selector dialog."""
    # Draw semi-transparent overlay
    overlay = pygame.Surface((int(APP_WIDTH), int(APP_HEIGHT)))
    overlay.set_alpha(200)
    overlay.fill((0, 0, 0))
    self.screen.blit(overlay, (0, 0))

    # Get dialog dimensions
    dialog_width, dialog_height = self._get_dialog_dimensions()
    dialog_x = int((APP_WIDTH - dialog_width) // 2)
    dialog_y = int((APP_HEIGHT - dialog_height) // 2)

    # Draw dialog background
    pygame.draw.rect(
        self.screen,
        (40, 40, 40),
        (dialog_x, dialog_y, dialog_width, dialog_height),
    )

    # Draw dialog border
    pygame.draw.rect(
        self.screen,
        (200, 200, 200),
        (dialog_x, dialog_y, dialog_width, dialog_height),
        3,
    )

    # Draw title
    font_size = int(APP_HEIGHT * 0.06)  # 6% of screen height
    font = pygame.font.Font(None, font_size)
    title_text = font.render(self.title, ANTI_ALIASING, (255, 255, 255))
    title_rect = title_text.get_rect(
        center=(int(APP_WIDTH // 2), dialog_y + int(APP_HEIGHT * 0.06))
    )
    self.screen.blit(title_text, title_rect)

    # Draw buttons
    for button in self.buttons:
        button.draw(self.screen)
_get_button_dimensions()

Get button dimensions (width, height, spacing).

Returns:

Type Description
tuple[int, int, int]

Tuple of (button_width, button_height, button_spacing).

Source code in notty/src/visual/number_selector.py
 96
 97
 98
 99
100
101
102
103
104
105
def _get_button_dimensions(self) -> tuple[int, int, int]:
    """Get button dimensions (width, height, spacing).

    Returns:
        Tuple of (button_width, button_height, button_spacing).
    """
    button_width = int(APP_WIDTH * 0.08)  # 8% of screen width
    button_height = int(APP_HEIGHT * 0.12)  # 12% of screen height
    button_spacing = int(APP_WIDTH * 0.015)  # 1.5% of screen width
    return button_width, button_height, button_spacing
_get_dialog_dimensions()

Get dialog dimensions.

Returns:

Type Description
tuple[int, int]

Tuple of (dialog_width, dialog_height).

Source code in notty/src/visual/number_selector.py
107
108
109
110
111
112
113
114
115
def _get_dialog_dimensions(self) -> tuple[int, int]:
    """Get dialog dimensions.

    Returns:
        Tuple of (dialog_width, dialog_height).
    """
    dialog_width = int(APP_WIDTH * 0.35)  # 35% of screen width
    dialog_height = int(APP_HEIGHT * 0.30)  # 30% of screen height
    return dialog_width, dialog_height
_get_selected_items()

Get the list of currently selected items.

Returns:

Type Description
list[T]

List of selected items.

Source code in notty/src/visual/base_selector.py
160
161
162
163
164
165
166
def _get_selected_items(self) -> list[T]:
    """Get the list of currently selected items.

    Returns:
        List of selected items.
    """
    return [button.item for button in self.buttons if button.selected]
_is_valid_selection()

Check if the current selection is valid.

Returns:

Type Description
bool

True if the selection is valid.

Source code in notty/src/visual/base_selector.py
168
169
170
171
172
173
174
175
176
177
178
179
def _is_valid_selection(self) -> bool:
    """Check if the current selection is valid.

    Returns:
        True if the selection is valid.
    """
    selected = self._get_selected_items()
    if not selected:
        return False
    if self.validation_func:
        return self.validation_func(selected)
    return len(selected) <= self.max_selections
_setup_buttons()

Set up the number buttons.

Source code in notty/src/visual/number_selector.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def _setup_buttons(self) -> None:
    """Set up the number buttons."""
    # Get button dimensions
    button_width, button_height, button_spacing = self._get_button_dimensions()

    # Center the buttons horizontally
    total_width = 3 * button_width + 2 * button_spacing
    start_x = int((APP_WIDTH - total_width) // 2)
    y = int(APP_HEIGHT // 2 - button_height // 2)

    # Create buttons for 1, 2, 3
    for i in range(3):
        number = i + 1
        x = start_x + i * (button_width + button_spacing)
        # Disable buttons that exceed max_number
        enabled = number <= self.max_number
        button = NumberButton(
            x, y, button_width, button_height, number, enabled=enabled
        )
        self.buttons.append(button)
show()

Show the selector and wait for user input.

Returns:

Type Description
list[T] | T | None

The selected item(s). Returns single item if max_selections=1, list otherwise.

list[T] | T | None

Returns None if no selection was made.

Source code in notty/src/visual/base_selector.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
def show(self) -> list[T] | T | None:
    """Show the selector and wait for user input.

    Returns:
        The selected item(s). Returns single item if max_selections=1,
            list otherwise.
        Returns None if no selection was made.
    """
    clock = pygame.time.Clock()

    while True:
        # Handle events
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.quit()
                raise SystemExit
            if event.type == pygame.MOUSEBUTTONDOWN:
                mouse_x, mouse_y = pygame.mouse.get_pos()

                # Check if any button was clicked
                for button in self.buttons:
                    if button.is_clicked(mouse_x, mouse_y):
                        if self.max_selections == 1:
                            # Single selection - return immediately
                            return button.item
                        # Multi-selection - toggle selection
                        current_count = len(self._get_selected_items())
                        button.toggle_selection(current_count, self.max_selections)
                        break

        # Update hover state
        mouse_x, mouse_y = pygame.mouse.get_pos()
        for button in self.buttons:
            button.update_hover(mouse_x, mouse_y)

        # Draw
        self._draw()

        # Update display
        pygame.display.flip()
        clock.tick(60)  # 60 FPS

player

visual player.

VisualHand

Bases: Visual

Represents a player's hand of cards.

Manages the collection of cards and enforces the 20-card limit.

Source code in notty/src/visual/player.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
class VisualHand(Visual):
    """Represents a player's hand of cards.

    Manages the collection of cards and enforces the 20-card limit.
    """

    def __init__(self, player: "VisualPlayer") -> None:
        """Initialize an empty hand."""
        self.player = player
        # x, y are dependent on players position
        x, y = player.x, player.y
        # make x is the same but y must be 5 times above the player
        y -= NUM_HAND_ROWS * CARD_HEIGHT
        super().__init__(
            x,
            y,
            HAND_HEIGHT,
            HAND_WIDTH,
            player.screen,
        )
        self.cards: list[VisualCard] = []

    def draw(self) -> None:
        """Draw the hand."""
        super().draw()
        for card in self.cards:
            card.draw()

    def get_png_name(self) -> str:
        """Get the png for the visual element."""
        return "hand"

    def get_png_pkg(self) -> ModuleType:
        """Get the png for the visual element."""
        return hand

    def hand_is_full(self) -> bool:
        """Check if the hand is full.

        Returns:
            True if hand has reached the maximum number of cards.
        """
        return self.size() >= MAX_HAND_SIZE

    def add_card(self, card: VisualCard, *, draw_discard_draw: bool = False) -> bool:
        """Add a card to the hand.

        Args:
            card: The card to add.
            draw_discard_draw: True if this is a draw and discard action.
                This is needed because in the draw and discard action, the player
                can draw even if hand is full.

        Returns:
            True if the card was added, False if hand is full.
        """
        if self.size() >= MAX_HAND_SIZE and not draw_discard_draw:
            msg = "Hand is full"
            raise ValueError(msg)
        self.cards.append(card)
        self.order_cards()
        return True

    def add_cards(self, cards: list[VisualCard]) -> dict[VisualCard, bool]:
        """Add multiple cards to the hand.

        Args:
            cards: List of cards to add.

        Returns:
            A dictionary mapping each card to a boolean indicating whether it was added.
        """
        cards_added: dict[VisualCard, bool] = {}
        for card in cards:
            cards_added[card] = self.add_card(card)
        return cards_added

    def remove_card(self, card: VisualCard) -> bool:
        """Remove a specific card from the hand.

        Args:
            card: The card to remove.

        Returns:
            True if the card was removed, False if card not in hand.
        """
        if card in self.cards:
            self.cards.remove(card)
            # reposition all cards in hand
            self.order_cards()
            return True
        return False

    def order_cards(self) -> None:
        """Order the cards in the hand."""
        self.cards.sort(key=lambda card: (card.color, card.number))
        for i, card in enumerate(self.cards):
            row = i // NUM_HAND_COLUMNS
            col = i % NUM_HAND_COLUMNS
            card_x = self.x + col * CARD_WIDTH
            card_y = self.y + row * CARD_HEIGHT
            card.move(card_x, card_y)

    def remove_cards(self, cards: list[VisualCard]) -> dict[VisualCard, bool]:
        """Remove multiple cards from the hand.

        Args:
            cards: List of cards to remove.

        Returns:
            A dictionary mapping each card to a boolean showing whether it was removed.
        """
        cards_removed: dict[VisualCard, bool] = {}
        for card in cards:
            cards_removed[card] = self.remove_card(card)
        return cards_removed

    def is_empty(self) -> bool:
        """Check if the hand is empty.

        Returns:
            True if hand has no cards.
        """
        return self.size() == 0

    def size(self) -> int:
        """Get the number of cards in the hand.

        Returns:
            Number of cards in hand.
        """
        return len(self.cards)

    def shuffle(self) -> None:
        """Shuffle the cards in the hand."""
        random.shuffle(self.cards)
__init__(player)

Initialize an empty hand.

Source code in notty/src/visual/player.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def __init__(self, player: "VisualPlayer") -> None:
    """Initialize an empty hand."""
    self.player = player
    # x, y are dependent on players position
    x, y = player.x, player.y
    # make x is the same but y must be 5 times above the player
    y -= NUM_HAND_ROWS * CARD_HEIGHT
    super().__init__(
        x,
        y,
        HAND_HEIGHT,
        HAND_WIDTH,
        player.screen,
    )
    self.cards: list[VisualCard] = []
add_card(card, *, draw_discard_draw=False)

Add a card to the hand.

Parameters:

Name Type Description Default
card VisualCard

The card to add.

required
draw_discard_draw bool

True if this is a draw and discard action. This is needed because in the draw and discard action, the player can draw even if hand is full.

False

Returns:

Type Description
bool

True if the card was added, False if hand is full.

Source code in notty/src/visual/player.py
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def add_card(self, card: VisualCard, *, draw_discard_draw: bool = False) -> bool:
    """Add a card to the hand.

    Args:
        card: The card to add.
        draw_discard_draw: True if this is a draw and discard action.
            This is needed because in the draw and discard action, the player
            can draw even if hand is full.

    Returns:
        True if the card was added, False if hand is full.
    """
    if self.size() >= MAX_HAND_SIZE and not draw_discard_draw:
        msg = "Hand is full"
        raise ValueError(msg)
    self.cards.append(card)
    self.order_cards()
    return True
add_cards(cards)

Add multiple cards to the hand.

Parameters:

Name Type Description Default
cards list[VisualCard]

List of cards to add.

required

Returns:

Type Description
dict[VisualCard, bool]

A dictionary mapping each card to a boolean indicating whether it was added.

Source code in notty/src/visual/player.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def add_cards(self, cards: list[VisualCard]) -> dict[VisualCard, bool]:
    """Add multiple cards to the hand.

    Args:
        cards: List of cards to add.

    Returns:
        A dictionary mapping each card to a boolean indicating whether it was added.
    """
    cards_added: dict[VisualCard, bool] = {}
    for card in cards:
        cards_added[card] = self.add_card(card)
    return cards_added
draw()

Draw the hand.

Source code in notty/src/visual/player.py
51
52
53
54
55
def draw(self) -> None:
    """Draw the hand."""
    super().draw()
    for card in self.cards:
        card.draw()
get_center()

Get the center of the visual element.

Source code in notty/src/visual/base.py
44
45
46
def get_center(self) -> tuple[int, int]:
    """Get the center of the visual element."""
    return self.x + self.width // 2, self.y + self.height // 2
get_png_name()

Get the png for the visual element.

Source code in notty/src/visual/player.py
57
58
59
def get_png_name(self) -> str:
    """Get the png for the visual element."""
    return "hand"
get_png_path()

Get the png for the visual element.

Source code in notty/src/visual/base.py
93
94
95
def get_png_path(self) -> Path:
    """Get the png for the visual element."""
    return resource_path(self.get_png_name() + ".png", self.get_png_pkg())
get_png_pkg()

Get the png for the visual element.

Source code in notty/src/visual/player.py
61
62
63
def get_png_pkg(self) -> ModuleType:
    """Get the png for the visual element."""
    return hand
hand_is_full()

Check if the hand is full.

Returns:

Type Description
bool

True if hand has reached the maximum number of cards.

Source code in notty/src/visual/player.py
65
66
67
68
69
70
71
def hand_is_full(self) -> bool:
    """Check if the hand is full.

    Returns:
        True if hand has reached the maximum number of cards.
    """
    return self.size() >= MAX_HAND_SIZE
is_empty()

Check if the hand is empty.

Returns:

Type Description
bool

True if hand has no cards.

Source code in notty/src/visual/player.py
146
147
148
149
150
151
152
def is_empty(self) -> bool:
    """Check if the hand is empty.

    Returns:
        True if hand has no cards.
    """
    return self.size() == 0
move(x, y)

Move the visual element.

Animates the movement in a straight line to that given location.

Parameters:

Name Type Description Default
x int

X coordinate.

required
y int

Y coordinate.

required
Source code in notty/src/visual/base.py
48
49
50
51
52
53
54
55
56
57
58
def move(self, x: int, y: int) -> None:
    """Move the visual element.

    Animates the movement in a straight line to that given location.

    Args:
        x: X coordinate.
        y: Y coordinate.
    """
    self.target_x = x
    self.target_y = y
order_cards()

Order the cards in the hand.

Source code in notty/src/visual/player.py
122
123
124
125
126
127
128
129
130
def order_cards(self) -> None:
    """Order the cards in the hand."""
    self.cards.sort(key=lambda card: (card.color, card.number))
    for i, card in enumerate(self.cards):
        row = i // NUM_HAND_COLUMNS
        col = i % NUM_HAND_COLUMNS
        card_x = self.x + col * CARD_WIDTH
        card_y = self.y + row * CARD_HEIGHT
        card.move(card_x, card_y)
remove_card(card)

Remove a specific card from the hand.

Parameters:

Name Type Description Default
card VisualCard

The card to remove.

required

Returns:

Type Description
bool

True if the card was removed, False if card not in hand.

Source code in notty/src/visual/player.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def remove_card(self, card: VisualCard) -> bool:
    """Remove a specific card from the hand.

    Args:
        card: The card to remove.

    Returns:
        True if the card was removed, False if card not in hand.
    """
    if card in self.cards:
        self.cards.remove(card)
        # reposition all cards in hand
        self.order_cards()
        return True
    return False
remove_cards(cards)

Remove multiple cards from the hand.

Parameters:

Name Type Description Default
cards list[VisualCard]

List of cards to remove.

required

Returns:

Type Description
dict[VisualCard, bool]

A dictionary mapping each card to a boolean showing whether it was removed.

Source code in notty/src/visual/player.py
132
133
134
135
136
137
138
139
140
141
142
143
144
def remove_cards(self, cards: list[VisualCard]) -> dict[VisualCard, bool]:
    """Remove multiple cards from the hand.

    Args:
        cards: List of cards to remove.

    Returns:
        A dictionary mapping each card to a boolean showing whether it was removed.
    """
    cards_removed: dict[VisualCard, bool] = {}
    for card in cards:
        cards_removed[card] = self.remove_card(card)
    return cards_removed
set_position(x, y)

Set the position of the visual element.

Parameters:

Name Type Description Default
x int

X coordinate.

required
y int

Y coordinate.

required
Source code in notty/src/visual/base.py
60
61
62
63
64
65
66
67
68
def set_position(self, x: int, y: int) -> None:
    """Set the position of the visual element.

    Args:
        x: X coordinate.
        y: Y coordinate.
    """
    self.x = x
    self.y = y
shuffle()

Shuffle the cards in the hand.

Source code in notty/src/visual/player.py
162
163
164
def shuffle(self) -> None:
    """Shuffle the cards in the hand."""
    random.shuffle(self.cards)
size()

Get the number of cards in the hand.

Returns:

Type Description
int

Number of cards in hand.

Source code in notty/src/visual/player.py
154
155
156
157
158
159
160
def size(self) -> int:
    """Get the number of cards in the hand.

    Returns:
        Number of cards in hand.
    """
    return len(self.cards)
VisualPlayer

Bases: Visual

Visual player.

Source code in notty/src/visual/player.py
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
class VisualPlayer(Visual):
    """Visual player."""

    ACTIVE_PLAYERS: ClassVar[list["VisualPlayer"]] = []

    TYPE_HUMAN = "human"
    TYPE_COMPUTER = "computer"

    def __init__(
        self,
        name: str,
        *,
        is_human: bool = False,
        screen: pygame.Surface,
    ) -> None:
        """Initialize a visual player."""
        VisualPlayer.ACTIVE_PLAYERS.append(self)
        self.name = name
        self.is_human = is_human
        x, y = self.get_coordinates()
        super().__init__(x, y, PLAYER_HEIGHT, PLAYER_WIDTH, screen)
        self.hand = VisualHand(player=self)

    def draw(self) -> None:
        """Draw the player."""
        super().draw()
        self.hand.draw()

    def get_png_name(self) -> str:
        """Get the png for the visual element."""
        return self.name

    def get_png_pkg(self) -> ModuleType:
        """Get the png for the visual element."""
        return players

    def get_coordinates(self) -> tuple[int, int]:
        """Get the coordinates for the player."""
        # make all xy dependent on APP_WIDTH and APP_HEIGHT
        y = APP_HEIGHT - PLAYER_HEIGHT
        # make position dependent on number of players
        # and index of player
        num_players = MAX_PLAYERS

        index = VisualPlayer.ACTIVE_PLAYERS.index(self)

        # Divide screen into equal spaces and center each player in their space
        player_space_width = APP_WIDTH // num_players

        # Center the player in their allocated space
        x = index * player_space_width

        return x, y

    @classmethod
    def get_all_player_names(cls) -> list[str]:
        """Get all player names."""
        # use importlib resources to gte all files in players package
        # that end with .png

        return [
            f.name.removesuffix(".png")
            for f in resources.files(players).iterdir()
            if f.name.endswith(".png")
        ]
__init__(name, *, is_human=False, screen)

Initialize a visual player.

Source code in notty/src/visual/player.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
def __init__(
    self,
    name: str,
    *,
    is_human: bool = False,
    screen: pygame.Surface,
) -> None:
    """Initialize a visual player."""
    VisualPlayer.ACTIVE_PLAYERS.append(self)
    self.name = name
    self.is_human = is_human
    x, y = self.get_coordinates()
    super().__init__(x, y, PLAYER_HEIGHT, PLAYER_WIDTH, screen)
    self.hand = VisualHand(player=self)
draw()

Draw the player.

Source code in notty/src/visual/player.py
190
191
192
193
def draw(self) -> None:
    """Draw the player."""
    super().draw()
    self.hand.draw()
get_all_player_names() classmethod

Get all player names.

Source code in notty/src/visual/player.py
221
222
223
224
225
226
227
228
229
230
231
@classmethod
def get_all_player_names(cls) -> list[str]:
    """Get all player names."""
    # use importlib resources to gte all files in players package
    # that end with .png

    return [
        f.name.removesuffix(".png")
        for f in resources.files(players).iterdir()
        if f.name.endswith(".png")
    ]
get_center()

Get the center of the visual element.

Source code in notty/src/visual/base.py
44
45
46
def get_center(self) -> tuple[int, int]:
    """Get the center of the visual element."""
    return self.x + self.width // 2, self.y + self.height // 2
get_coordinates()

Get the coordinates for the player.

Source code in notty/src/visual/player.py
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def get_coordinates(self) -> tuple[int, int]:
    """Get the coordinates for the player."""
    # make all xy dependent on APP_WIDTH and APP_HEIGHT
    y = APP_HEIGHT - PLAYER_HEIGHT
    # make position dependent on number of players
    # and index of player
    num_players = MAX_PLAYERS

    index = VisualPlayer.ACTIVE_PLAYERS.index(self)

    # Divide screen into equal spaces and center each player in their space
    player_space_width = APP_WIDTH // num_players

    # Center the player in their allocated space
    x = index * player_space_width

    return x, y
get_png_name()

Get the png for the visual element.

Source code in notty/src/visual/player.py
195
196
197
def get_png_name(self) -> str:
    """Get the png for the visual element."""
    return self.name
get_png_path()

Get the png for the visual element.

Source code in notty/src/visual/base.py
93
94
95
def get_png_path(self) -> Path:
    """Get the png for the visual element."""
    return resource_path(self.get_png_name() + ".png", self.get_png_pkg())
get_png_pkg()

Get the png for the visual element.

Source code in notty/src/visual/player.py
199
200
201
def get_png_pkg(self) -> ModuleType:
    """Get the png for the visual element."""
    return players
move(x, y)

Move the visual element.

Animates the movement in a straight line to that given location.

Parameters:

Name Type Description Default
x int

X coordinate.

required
y int

Y coordinate.

required
Source code in notty/src/visual/base.py
48
49
50
51
52
53
54
55
56
57
58
def move(self, x: int, y: int) -> None:
    """Move the visual element.

    Animates the movement in a straight line to that given location.

    Args:
        x: X coordinate.
        y: Y coordinate.
    """
    self.target_x = x
    self.target_y = y
set_position(x, y)

Set the position of the visual element.

Parameters:

Name Type Description Default
x int

X coordinate.

required
y int

Y coordinate.

required
Source code in notty/src/visual/base.py
60
61
62
63
64
65
66
67
68
def set_position(self, x: int, y: int) -> None:
    """Set the position of the visual element.

    Args:
        x: X coordinate.
        y: Y coordinate.
    """
    self.x = x
    self.y = y

player_name_selector

Player name selector for initial player selection.

PlayerNameButton

Bases: SelectableButton[str]

Represents a clickable player name button with image.

Source code in notty/src/visual/player_name_selector.py
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
class PlayerNameButton(SelectableButton[str]):
    """Represents a clickable player name button with image."""

    def __init__(  # noqa: PLR0913
        self,
        x: int,
        y: int,
        width: int,
        height: int,
        player_name: str,
        player_image: pygame.Surface,
        *,
        enabled: bool = True,
        selectable: bool = False,
    ) -> None:
        """Initialize a player name button.

        Args:
            x: X coordinate of the button.
            y: Y coordinate of the button.
            width: Width of the button.
            height: Height of the button.
            player_name: The name of the player.
            player_image: The image of the player.
            enabled: Whether the button is enabled.
            selectable: Whether the button can be selected/deselected.
        """
        super().__init__(
            x,
            y,
            width,
            height,
            player_name,
            player_image,
            enabled=enabled,
            selectable=selectable,
        )
        self.player_name = player_name
        self.player_image = player_image

    def draw(self, screen: pygame.Surface) -> None:
        """Draw the button.

        Args:
            screen: The pygame display surface.
        """
        # Determine border color based on state
        if self.selected:
            border_color = (50, 255, 50)  # Green for selected
            border_width = 5
        elif self.hovered:
            border_color = (100, 200, 255)  # Light blue for hover
            border_width = 5
        else:
            border_color = (255, 255, 255)  # White
            border_width = 2

        # Draw border
        border_padding = int(APP_WIDTH * 0.008)  # 0.8% of screen width
        pygame.draw.rect(
            screen,
            border_color,
            (
                self.x - border_padding,
                self.y - border_padding,
                self.width + 2 * border_padding,
                self.height + 2 * border_padding,
            ),
            border_width,
        )

        # Draw player image
        screen.blit(self.player_image, (self.x, self.y))

        # Draw player name
        name_font_size = int(APP_HEIGHT * 0.06)  # 6% of screen height
        name_font = pygame.font.Font(None, name_font_size)
        color = (
            (50, 255, 50)
            if self.selected
            else ((100, 200, 255) if self.hovered else (255, 255, 255))
        )
        name_text = name_font.render(
            self.player_name.capitalize(), ANTI_ALIASING, color
        )
        name_rect = name_text.get_rect(
            center=(
                self.x + self.width // 2,
                self.y + self.height + int(APP_HEIGHT * 0.04),
            )
        )
        screen.blit(name_text, name_rect)
__init__(x, y, width, height, player_name, player_image, *, enabled=True, selectable=False)

Initialize a player name button.

Parameters:

Name Type Description Default
x int

X coordinate of the button.

required
y int

Y coordinate of the button.

required
width int

Width of the button.

required
height int

Height of the button.

required
player_name str

The name of the player.

required
player_image Surface

The image of the player.

required
enabled bool

Whether the button is enabled.

True
selectable bool

Whether the button can be selected/deselected.

False
Source code in notty/src/visual/player_name_selector.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def __init__(  # noqa: PLR0913
    self,
    x: int,
    y: int,
    width: int,
    height: int,
    player_name: str,
    player_image: pygame.Surface,
    *,
    enabled: bool = True,
    selectable: bool = False,
) -> None:
    """Initialize a player name button.

    Args:
        x: X coordinate of the button.
        y: Y coordinate of the button.
        width: Width of the button.
        height: Height of the button.
        player_name: The name of the player.
        player_image: The image of the player.
        enabled: Whether the button is enabled.
        selectable: Whether the button can be selected/deselected.
    """
    super().__init__(
        x,
        y,
        width,
        height,
        player_name,
        player_image,
        enabled=enabled,
        selectable=selectable,
    )
    self.player_name = player_name
    self.player_image = player_image
draw(screen)

Draw the button.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required
Source code in notty/src/visual/player_name_selector.py
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def draw(self, screen: pygame.Surface) -> None:
    """Draw the button.

    Args:
        screen: The pygame display surface.
    """
    # Determine border color based on state
    if self.selected:
        border_color = (50, 255, 50)  # Green for selected
        border_width = 5
    elif self.hovered:
        border_color = (100, 200, 255)  # Light blue for hover
        border_width = 5
    else:
        border_color = (255, 255, 255)  # White
        border_width = 2

    # Draw border
    border_padding = int(APP_WIDTH * 0.008)  # 0.8% of screen width
    pygame.draw.rect(
        screen,
        border_color,
        (
            self.x - border_padding,
            self.y - border_padding,
            self.width + 2 * border_padding,
            self.height + 2 * border_padding,
        ),
        border_width,
    )

    # Draw player image
    screen.blit(self.player_image, (self.x, self.y))

    # Draw player name
    name_font_size = int(APP_HEIGHT * 0.06)  # 6% of screen height
    name_font = pygame.font.Font(None, name_font_size)
    color = (
        (50, 255, 50)
        if self.selected
        else ((100, 200, 255) if self.hovered else (255, 255, 255))
    )
    name_text = name_font.render(
        self.player_name.capitalize(), ANTI_ALIASING, color
    )
    name_rect = name_text.get_rect(
        center=(
            self.x + self.width // 2,
            self.y + self.height + int(APP_HEIGHT * 0.04),
        )
    )
    screen.blit(name_text, name_rect)
is_clicked(mouse_x, mouse_y)

Check if the button was clicked.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required

Returns:

Type Description
bool

True if the button was clicked and is enabled.

Source code in notty/src/visual/base_selector.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def is_clicked(self, mouse_x: int, mouse_y: int) -> bool:
    """Check if the button was clicked.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.

    Returns:
        True if the button was clicked and is enabled.
    """
    if not self.enabled:
        return False
    return (
        self.x <= mouse_x <= self.x + self.width
        and self.y <= mouse_y <= self.y + self.height
    )
toggle_selection(current_selected_count, max_selections)

Toggle the selection state of this button.

Parameters:

Name Type Description Default
current_selected_count int

Number of currently selected items.

required
max_selections int

Maximum number of selections allowed.

required
Source code in notty/src/visual/base_selector.py
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def toggle_selection(
    self, current_selected_count: int, max_selections: int
) -> None:
    """Toggle the selection state of this button.

    Args:
        current_selected_count: Number of currently selected items.
        max_selections: Maximum number of selections allowed.
    """
    if self.selectable:
        if self.selected:
            # Always allow deselection
            self.selected = False
        elif current_selected_count < max_selections:
            # Only allow selection if under max
            self.selected = True
update_hover(mouse_x, mouse_y)

Update hover state based on mouse position.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required
Source code in notty/src/visual/base_selector.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def update_hover(self, mouse_x: int, mouse_y: int) -> None:
    """Update hover state based on mouse position.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.
    """
    if not self.enabled:
        self.hovered = False
        return
    self.hovered = (
        self.x <= mouse_x <= self.x + self.width
        and self.y <= mouse_y <= self.y + self.height
    )
PlayerNameSelector

Bases: BaseSelector[str]

Dialog for selecting player name(s) at game start.

Source code in notty/src/visual/player_name_selector.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
class PlayerNameSelector(BaseSelector[str]):
    """Dialog for selecting player name(s) at game start."""

    def __init__(
        self,
        screen: pygame.Surface,
        available_names: list[str],
        title: str,
        *,
        max_selections: int = 1,
        min_selections: int = 1,
    ) -> None:
        """Initialize the player name selector.

        Args:
            screen: The pygame display surface.
            available_names: List of available player names.
            title: Title to display.
            max_selections: Maximum number of selections allowed.
            min_selections: Minimum number of selections required.
        """
        self.min_selections = min_selections
        self.needs_submit = max_selections > 1
        super().__init__(
            screen,
            title=title,
            items=available_names,
            max_selections=max_selections,
        )

    def _get_button_dimensions(self) -> tuple[int, int, int]:
        """Get button dimensions (width, height, spacing).

        Returns:
            Tuple of (button_width, button_height, button_spacing).
        """
        image_size = int(APP_WIDTH * 0.12)  # 12% of screen width
        spacing = int(APP_WIDTH * 0.02)  # 2% of screen width
        return image_size, image_size, spacing

    def _get_dialog_dimensions(self) -> tuple[int, int]:
        """Get dialog dimensions.

        Returns:
            Tuple of (dialog_width, dialog_height).
        """
        # Full screen for player selection
        return APP_WIDTH, APP_HEIGHT

    def _setup_buttons(self) -> None:
        """Set up the player name buttons."""
        image_size, _, _spacing = self._get_button_dimensions()

        # Load player images
        player_images: dict[str, pygame.Surface] = {}
        for name in self.items:
            png_path = resource_path(name + ".png", players)
            img = pygame.image.load(png_path)
            player_images[name] = pygame.transform.scale(img, (image_size, image_size))

        # Calculate positions - center horizontally
        player_spacing = APP_WIDTH // (len(self.items) + 1)
        for i, name in enumerate(self.items):
            x = player_spacing * (i + 1) - image_size // 2
            y = APP_HEIGHT // 2 - image_size // 2

            button = PlayerNameButton(
                x,
                y,
                image_size,
                image_size,
                name,
                player_images[name],
                enabled=True,
                selectable=self.needs_submit,
            )
            self.buttons.append(button)

    def _draw(self) -> None:
        """Draw the player name selector."""
        # Draw black background (no overlay for full screen)
        self.screen.fill((0, 0, 0))

        # Draw title
        title_font_size = int(APP_HEIGHT * 0.09)  # 9% of screen height
        title_font = pygame.font.Font(None, title_font_size)
        title_text = title_font.render(self.title, ANTI_ALIASING, (255, 255, 255))
        title_rect = title_text.get_rect(
            center=(APP_WIDTH // 2, int(APP_HEIGHT * 0.12))
        )
        self.screen.blit(title_text, title_rect)

        # Draw instruction
        instruction_font_size = int(APP_HEIGHT * 0.045)  # 4.5% of screen height
        instruction_font = pygame.font.Font(None, instruction_font_size)
        if self.needs_submit:
            instruction = "Click to select/deselect • Press ENTER when done"
        else:
            instruction = "Click on a player to select"
        instruction_text = instruction_font.render(
            instruction, ANTI_ALIASING, (255, 255, 255)
        )
        instruction_rect = instruction_text.get_rect(
            center=(APP_WIDTH // 2, int(APP_HEIGHT * 0.22))
        )
        self.screen.blit(instruction_text, instruction_rect)

        # Draw buttons
        for button in self.buttons:
            button.draw(self.screen)

    def show(self) -> str | list[str]:  # noqa: C901
        """Show the player name selector and wait for user input.

        Returns:
            Selected player name (single) or list of names (multiple).
        """
        clock = pygame.time.Clock()

        while True:
            # Handle events
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    pygame.quit()
                    raise SystemExit
                if event.type == pygame.MOUSEBUTTONDOWN:
                    mouse_x, mouse_y = pygame.mouse.get_pos()
                    for button in self.buttons:
                        if button.is_clicked(mouse_x, mouse_y):
                            if self.needs_submit:
                                # Multi-select mode: toggle selection
                                current_count = len(self._get_selected_items())
                                button.toggle_selection(
                                    current_count, self.max_selections
                                )
                            else:
                                # Single-select mode: return immediately
                                return button.item
                elif event.type == pygame.KEYDOWN and self.needs_submit:
                    if event.key == pygame.K_RETURN:
                        selected = self._get_selected_items()
                        if len(selected) >= self.min_selections:
                            return selected

            # Update hover state
            mouse_x, mouse_y = pygame.mouse.get_pos()
            for button in self.buttons:
                button.update_hover(mouse_x, mouse_y)

            # Draw
            self._draw()

            # Update display
            pygame.display.flip()
            clock.tick(60)  # 60 FPS
__init__(screen, available_names, title, *, max_selections=1, min_selections=1)

Initialize the player name selector.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required
available_names list[str]

List of available player names.

required
title str

Title to display.

required
max_selections int

Maximum number of selections allowed.

1
min_selections int

Minimum number of selections required.

1
Source code in notty/src/visual/player_name_selector.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def __init__(
    self,
    screen: pygame.Surface,
    available_names: list[str],
    title: str,
    *,
    max_selections: int = 1,
    min_selections: int = 1,
) -> None:
    """Initialize the player name selector.

    Args:
        screen: The pygame display surface.
        available_names: List of available player names.
        title: Title to display.
        max_selections: Maximum number of selections allowed.
        min_selections: Minimum number of selections required.
    """
    self.min_selections = min_selections
    self.needs_submit = max_selections > 1
    super().__init__(
        screen,
        title=title,
        items=available_names,
        max_selections=max_selections,
    )
_draw()

Draw the player name selector.

Source code in notty/src/visual/player_name_selector.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
def _draw(self) -> None:
    """Draw the player name selector."""
    # Draw black background (no overlay for full screen)
    self.screen.fill((0, 0, 0))

    # Draw title
    title_font_size = int(APP_HEIGHT * 0.09)  # 9% of screen height
    title_font = pygame.font.Font(None, title_font_size)
    title_text = title_font.render(self.title, ANTI_ALIASING, (255, 255, 255))
    title_rect = title_text.get_rect(
        center=(APP_WIDTH // 2, int(APP_HEIGHT * 0.12))
    )
    self.screen.blit(title_text, title_rect)

    # Draw instruction
    instruction_font_size = int(APP_HEIGHT * 0.045)  # 4.5% of screen height
    instruction_font = pygame.font.Font(None, instruction_font_size)
    if self.needs_submit:
        instruction = "Click to select/deselect • Press ENTER when done"
    else:
        instruction = "Click on a player to select"
    instruction_text = instruction_font.render(
        instruction, ANTI_ALIASING, (255, 255, 255)
    )
    instruction_rect = instruction_text.get_rect(
        center=(APP_WIDTH // 2, int(APP_HEIGHT * 0.22))
    )
    self.screen.blit(instruction_text, instruction_rect)

    # Draw buttons
    for button in self.buttons:
        button.draw(self.screen)
_get_button_dimensions()

Get button dimensions (width, height, spacing).

Returns:

Type Description
tuple[int, int, int]

Tuple of (button_width, button_height, button_spacing).

Source code in notty/src/visual/player_name_selector.py
135
136
137
138
139
140
141
142
143
def _get_button_dimensions(self) -> tuple[int, int, int]:
    """Get button dimensions (width, height, spacing).

    Returns:
        Tuple of (button_width, button_height, button_spacing).
    """
    image_size = int(APP_WIDTH * 0.12)  # 12% of screen width
    spacing = int(APP_WIDTH * 0.02)  # 2% of screen width
    return image_size, image_size, spacing
_get_dialog_dimensions()

Get dialog dimensions.

Returns:

Type Description
tuple[int, int]

Tuple of (dialog_width, dialog_height).

Source code in notty/src/visual/player_name_selector.py
145
146
147
148
149
150
151
152
def _get_dialog_dimensions(self) -> tuple[int, int]:
    """Get dialog dimensions.

    Returns:
        Tuple of (dialog_width, dialog_height).
    """
    # Full screen for player selection
    return APP_WIDTH, APP_HEIGHT
_get_selected_items()

Get the list of currently selected items.

Returns:

Type Description
list[T]

List of selected items.

Source code in notty/src/visual/base_selector.py
160
161
162
163
164
165
166
def _get_selected_items(self) -> list[T]:
    """Get the list of currently selected items.

    Returns:
        List of selected items.
    """
    return [button.item for button in self.buttons if button.selected]
_is_valid_selection()

Check if the current selection is valid.

Returns:

Type Description
bool

True if the selection is valid.

Source code in notty/src/visual/base_selector.py
168
169
170
171
172
173
174
175
176
177
178
179
def _is_valid_selection(self) -> bool:
    """Check if the current selection is valid.

    Returns:
        True if the selection is valid.
    """
    selected = self._get_selected_items()
    if not selected:
        return False
    if self.validation_func:
        return self.validation_func(selected)
    return len(selected) <= self.max_selections
_setup_buttons()

Set up the player name buttons.

Source code in notty/src/visual/player_name_selector.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
def _setup_buttons(self) -> None:
    """Set up the player name buttons."""
    image_size, _, _spacing = self._get_button_dimensions()

    # Load player images
    player_images: dict[str, pygame.Surface] = {}
    for name in self.items:
        png_path = resource_path(name + ".png", players)
        img = pygame.image.load(png_path)
        player_images[name] = pygame.transform.scale(img, (image_size, image_size))

    # Calculate positions - center horizontally
    player_spacing = APP_WIDTH // (len(self.items) + 1)
    for i, name in enumerate(self.items):
        x = player_spacing * (i + 1) - image_size // 2
        y = APP_HEIGHT // 2 - image_size // 2

        button = PlayerNameButton(
            x,
            y,
            image_size,
            image_size,
            name,
            player_images[name],
            enabled=True,
            selectable=self.needs_submit,
        )
        self.buttons.append(button)
show()

Show the player name selector and wait for user input.

Returns:

Type Description
str | list[str]

Selected player name (single) or list of names (multiple).

Source code in notty/src/visual/player_name_selector.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
def show(self) -> str | list[str]:  # noqa: C901
    """Show the player name selector and wait for user input.

    Returns:
        Selected player name (single) or list of names (multiple).
    """
    clock = pygame.time.Clock()

    while True:
        # Handle events
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.quit()
                raise SystemExit
            if event.type == pygame.MOUSEBUTTONDOWN:
                mouse_x, mouse_y = pygame.mouse.get_pos()
                for button in self.buttons:
                    if button.is_clicked(mouse_x, mouse_y):
                        if self.needs_submit:
                            # Multi-select mode: toggle selection
                            current_count = len(self._get_selected_items())
                            button.toggle_selection(
                                current_count, self.max_selections
                            )
                        else:
                            # Single-select mode: return immediately
                            return button.item
            elif event.type == pygame.KEYDOWN and self.needs_submit:
                if event.key == pygame.K_RETURN:
                    selected = self._get_selected_items()
                    if len(selected) >= self.min_selections:
                        return selected

        # Update hover state
        mouse_x, mouse_y = pygame.mouse.get_pos()
        for button in self.buttons:
            button.update_hover(mouse_x, mouse_y)

        # Draw
        self._draw()

        # Update display
        pygame.display.flip()
        clock.tick(60)  # 60 FPS

player_selector

Player selector dialog for choosing which player to steal from.

PlayerButton

Bases: SelectableButton['VisualPlayer']

Represents a clickable player button.

Source code in notty/src/visual/player_selector.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
class PlayerButton(SelectableButton["VisualPlayer"]):
    """Represents a clickable player button."""

    def __init__(  # noqa: PLR0913
        self,
        x: int,
        y: int,
        width: int,
        height: int,
        player: "VisualPlayer",
        player_image: pygame.Surface,
    ) -> None:
        """Initialize a player button.

        Args:
            x: X coordinate of the button.
            y: Y coordinate of the button.
            width: Width of the button.
            height: Height of the button.
            player: The player this button represents.
            player_image: The image of the player.
        """
        super().__init__(
            x, y, width, height, player, player_image, enabled=True, selectable=False
        )
        self.player = player
        self.player_image = player_image

    def draw(self, screen: pygame.Surface) -> None:
        """Draw the button.

        Args:
            screen: The pygame display surface.
        """
        # Determine border color based on state
        if self.hovered:
            border_color = (100, 200, 255)  # Light blue for hover
            border_width = 5
        else:
            border_color = (255, 255, 255)  # White
            border_width = 3

        # Draw border
        border_padding = 10
        pygame.draw.rect(
            screen,
            border_color,
            (
                self.x - border_padding,
                self.y - border_padding,
                self.width + 2 * border_padding,
                self.height + 2 * border_padding,
            ),
            border_width,
        )

        # Draw player image
        screen.blit(self.player_image, (self.x, self.y))

        # Draw player name below the image - scale font size
        font_size = int(self.height * 0.24)  # 24% of image height
        font = pygame.font.Font(None, font_size)
        text_color = (100, 200, 255) if self.hovered else (255, 255, 255)
        text_surface = font.render(self.player.name, ANTI_ALIASING, text_color)
        text_rect = text_surface.get_rect(
            center=(
                self.x + self.width // 2,
                self.y + self.height + int(self.height * 0.2),
            )
        )
        screen.blit(text_surface, text_rect)
__init__(x, y, width, height, player, player_image)

Initialize a player button.

Parameters:

Name Type Description Default
x int

X coordinate of the button.

required
y int

Y coordinate of the button.

required
width int

Width of the button.

required
height int

Height of the button.

required
player VisualPlayer

The player this button represents.

required
player_image Surface

The image of the player.

required
Source code in notty/src/visual/player_selector.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def __init__(  # noqa: PLR0913
    self,
    x: int,
    y: int,
    width: int,
    height: int,
    player: "VisualPlayer",
    player_image: pygame.Surface,
) -> None:
    """Initialize a player button.

    Args:
        x: X coordinate of the button.
        y: Y coordinate of the button.
        width: Width of the button.
        height: Height of the button.
        player: The player this button represents.
        player_image: The image of the player.
    """
    super().__init__(
        x, y, width, height, player, player_image, enabled=True, selectable=False
    )
    self.player = player
    self.player_image = player_image
draw(screen)

Draw the button.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required
Source code in notty/src/visual/player_selector.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
def draw(self, screen: pygame.Surface) -> None:
    """Draw the button.

    Args:
        screen: The pygame display surface.
    """
    # Determine border color based on state
    if self.hovered:
        border_color = (100, 200, 255)  # Light blue for hover
        border_width = 5
    else:
        border_color = (255, 255, 255)  # White
        border_width = 3

    # Draw border
    border_padding = 10
    pygame.draw.rect(
        screen,
        border_color,
        (
            self.x - border_padding,
            self.y - border_padding,
            self.width + 2 * border_padding,
            self.height + 2 * border_padding,
        ),
        border_width,
    )

    # Draw player image
    screen.blit(self.player_image, (self.x, self.y))

    # Draw player name below the image - scale font size
    font_size = int(self.height * 0.24)  # 24% of image height
    font = pygame.font.Font(None, font_size)
    text_color = (100, 200, 255) if self.hovered else (255, 255, 255)
    text_surface = font.render(self.player.name, ANTI_ALIASING, text_color)
    text_rect = text_surface.get_rect(
        center=(
            self.x + self.width // 2,
            self.y + self.height + int(self.height * 0.2),
        )
    )
    screen.blit(text_surface, text_rect)
is_clicked(mouse_x, mouse_y)

Check if the button was clicked.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required

Returns:

Type Description
bool

True if the button was clicked and is enabled.

Source code in notty/src/visual/base_selector.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def is_clicked(self, mouse_x: int, mouse_y: int) -> bool:
    """Check if the button was clicked.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.

    Returns:
        True if the button was clicked and is enabled.
    """
    if not self.enabled:
        return False
    return (
        self.x <= mouse_x <= self.x + self.width
        and self.y <= mouse_y <= self.y + self.height
    )
toggle_selection(current_selected_count, max_selections)

Toggle the selection state of this button.

Parameters:

Name Type Description Default
current_selected_count int

Number of currently selected items.

required
max_selections int

Maximum number of selections allowed.

required
Source code in notty/src/visual/base_selector.py
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def toggle_selection(
    self, current_selected_count: int, max_selections: int
) -> None:
    """Toggle the selection state of this button.

    Args:
        current_selected_count: Number of currently selected items.
        max_selections: Maximum number of selections allowed.
    """
    if self.selectable:
        if self.selected:
            # Always allow deselection
            self.selected = False
        elif current_selected_count < max_selections:
            # Only allow selection if under max
            self.selected = True
update_hover(mouse_x, mouse_y)

Update hover state based on mouse position.

Parameters:

Name Type Description Default
mouse_x int

Mouse x coordinate.

required
mouse_y int

Mouse y coordinate.

required
Source code in notty/src/visual/base_selector.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def update_hover(self, mouse_x: int, mouse_y: int) -> None:
    """Update hover state based on mouse position.

    Args:
        mouse_x: Mouse x coordinate.
        mouse_y: Mouse y coordinate.
    """
    if not self.enabled:
        self.hovered = False
        return
    self.hovered = (
        self.x <= mouse_x <= self.x + self.width
        and self.y <= mouse_y <= self.y + self.height
    )
PlayerSelector

Bases: BaseSelector['VisualPlayer']

Dialog for selecting a player to steal from.

Source code in notty/src/visual/player_selector.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
class PlayerSelector(BaseSelector["VisualPlayer"]):
    """Dialog for selecting a player to steal from."""

    def __init__(
        self, screen: pygame.Surface, available_players: list["VisualPlayer"]
    ) -> None:
        """Initialize the player selector.

        Args:
            screen: The pygame display surface.
            available_players: List of players that can be selected.
        """
        super().__init__(
            screen,
            title="Choose a player to steal from",
            items=available_players,
            max_selections=1,
        )

    def _get_button_dimensions(self) -> tuple[int, int, int]:
        """Get button dimensions (width, height, spacing).

        Returns:
            Tuple of (button_width, button_height, button_spacing).
        """
        image_size = int(APP_HEIGHT * 0.18)  # 18% of screen height
        button_spacing = int(APP_WIDTH * 0.03)  # 3% of screen width
        return image_size, image_size, button_spacing

    def _get_dialog_dimensions(self) -> tuple[int, int]:
        """Get dialog dimensions.

        Returns:
            Tuple of (dialog_width, dialog_height).
        """
        dialog_width = int(APP_WIDTH * 0.52)  # 52% of screen width
        dialog_height = int(APP_HEIGHT * 0.42)  # 42% of screen height
        return dialog_width, dialog_height

    def _setup_buttons(self) -> None:
        """Set up the player buttons."""
        # Get button dimensions
        image_size, _, button_spacing = self._get_button_dimensions()

        # Calculate total width needed
        num_players = len(self.items)
        total_width = num_players * image_size + (num_players - 1) * button_spacing
        start_x = int((APP_WIDTH - total_width) // 2)
        y = int(APP_HEIGHT // 2 - image_size // 2)

        # Create buttons for each player
        for i, player in enumerate(self.items):
            x = start_x + i * (image_size + button_spacing)
            # Load and scale player image
            player_image = pygame.transform.scale(player.png, (image_size, image_size))
            button = PlayerButton(x, y, image_size, image_size, player, player_image)
            self.buttons.append(button)
__init__(screen, available_players)

Initialize the player selector.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required
available_players list[VisualPlayer]

List of players that can be selected.

required
Source code in notty/src/visual/player_selector.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def __init__(
    self, screen: pygame.Surface, available_players: list["VisualPlayer"]
) -> None:
    """Initialize the player selector.

    Args:
        screen: The pygame display surface.
        available_players: List of players that can be selected.
    """
    super().__init__(
        screen,
        title="Choose a player to steal from",
        items=available_players,
        max_selections=1,
    )
_draw()

Draw the selector dialog.

Source code in notty/src/visual/base_selector.py
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
def _draw(self) -> None:
    """Draw the selector dialog."""
    # Draw semi-transparent overlay
    overlay = pygame.Surface((int(APP_WIDTH), int(APP_HEIGHT)))
    overlay.set_alpha(200)
    overlay.fill((0, 0, 0))
    self.screen.blit(overlay, (0, 0))

    # Get dialog dimensions
    dialog_width, dialog_height = self._get_dialog_dimensions()
    dialog_x = int((APP_WIDTH - dialog_width) // 2)
    dialog_y = int((APP_HEIGHT - dialog_height) // 2)

    # Draw dialog background
    pygame.draw.rect(
        self.screen,
        (40, 40, 40),
        (dialog_x, dialog_y, dialog_width, dialog_height),
    )

    # Draw dialog border
    pygame.draw.rect(
        self.screen,
        (200, 200, 200),
        (dialog_x, dialog_y, dialog_width, dialog_height),
        3,
    )

    # Draw title
    font_size = int(APP_HEIGHT * 0.06)  # 6% of screen height
    font = pygame.font.Font(None, font_size)
    title_text = font.render(self.title, ANTI_ALIASING, (255, 255, 255))
    title_rect = title_text.get_rect(
        center=(int(APP_WIDTH // 2), dialog_y + int(APP_HEIGHT * 0.06))
    )
    self.screen.blit(title_text, title_rect)

    # Draw buttons
    for button in self.buttons:
        button.draw(self.screen)
_get_button_dimensions()

Get button dimensions (width, height, spacing).

Returns:

Type Description
tuple[int, int, int]

Tuple of (button_width, button_height, button_spacing).

Source code in notty/src/visual/player_selector.py
106
107
108
109
110
111
112
113
114
def _get_button_dimensions(self) -> tuple[int, int, int]:
    """Get button dimensions (width, height, spacing).

    Returns:
        Tuple of (button_width, button_height, button_spacing).
    """
    image_size = int(APP_HEIGHT * 0.18)  # 18% of screen height
    button_spacing = int(APP_WIDTH * 0.03)  # 3% of screen width
    return image_size, image_size, button_spacing
_get_dialog_dimensions()

Get dialog dimensions.

Returns:

Type Description
tuple[int, int]

Tuple of (dialog_width, dialog_height).

Source code in notty/src/visual/player_selector.py
116
117
118
119
120
121
122
123
124
def _get_dialog_dimensions(self) -> tuple[int, int]:
    """Get dialog dimensions.

    Returns:
        Tuple of (dialog_width, dialog_height).
    """
    dialog_width = int(APP_WIDTH * 0.52)  # 52% of screen width
    dialog_height = int(APP_HEIGHT * 0.42)  # 42% of screen height
    return dialog_width, dialog_height
_get_selected_items()

Get the list of currently selected items.

Returns:

Type Description
list[T]

List of selected items.

Source code in notty/src/visual/base_selector.py
160
161
162
163
164
165
166
def _get_selected_items(self) -> list[T]:
    """Get the list of currently selected items.

    Returns:
        List of selected items.
    """
    return [button.item for button in self.buttons if button.selected]
_is_valid_selection()

Check if the current selection is valid.

Returns:

Type Description
bool

True if the selection is valid.

Source code in notty/src/visual/base_selector.py
168
169
170
171
172
173
174
175
176
177
178
179
def _is_valid_selection(self) -> bool:
    """Check if the current selection is valid.

    Returns:
        True if the selection is valid.
    """
    selected = self._get_selected_items()
    if not selected:
        return False
    if self.validation_func:
        return self.validation_func(selected)
    return len(selected) <= self.max_selections
_setup_buttons()

Set up the player buttons.

Source code in notty/src/visual/player_selector.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def _setup_buttons(self) -> None:
    """Set up the player buttons."""
    # Get button dimensions
    image_size, _, button_spacing = self._get_button_dimensions()

    # Calculate total width needed
    num_players = len(self.items)
    total_width = num_players * image_size + (num_players - 1) * button_spacing
    start_x = int((APP_WIDTH - total_width) // 2)
    y = int(APP_HEIGHT // 2 - image_size // 2)

    # Create buttons for each player
    for i, player in enumerate(self.items):
        x = start_x + i * (image_size + button_spacing)
        # Load and scale player image
        player_image = pygame.transform.scale(player.png, (image_size, image_size))
        button = PlayerButton(x, y, image_size, image_size, player, player_image)
        self.buttons.append(button)
show()

Show the selector and wait for user input.

Returns:

Type Description
list[T] | T | None

The selected item(s). Returns single item if max_selections=1, list otherwise.

list[T] | T | None

Returns None if no selection was made.

Source code in notty/src/visual/base_selector.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
def show(self) -> list[T] | T | None:
    """Show the selector and wait for user input.

    Returns:
        The selected item(s). Returns single item if max_selections=1,
            list otherwise.
        Returns None if no selection was made.
    """
    clock = pygame.time.Clock()

    while True:
        # Handle events
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.quit()
                raise SystemExit
            if event.type == pygame.MOUSEBUTTONDOWN:
                mouse_x, mouse_y = pygame.mouse.get_pos()

                # Check if any button was clicked
                for button in self.buttons:
                    if button.is_clicked(mouse_x, mouse_y):
                        if self.max_selections == 1:
                            # Single selection - return immediately
                            return button.item
                        # Multi-selection - toggle selection
                        current_count = len(self._get_selected_items())
                        button.toggle_selection(current_count, self.max_selections)
                        break

        # Update hover state
        mouse_x, mouse_y = pygame.mouse.get_pos()
        for button in self.buttons:
            button.update_hover(mouse_x, mouse_y)

        # Draw
        self._draw()

        # Update display
        pygame.display.flip()
        clock.tick(60)  # 60 FPS

winner_display

Winner display dialog for showing the game winner.

WinnerDisplay

Dialog for displaying the winner of the game.

Source code in notty/src/visual/winner_display.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
class WinnerDisplay:
    """Dialog for displaying the winner of the game."""

    def __init__(self, screen: pygame.Surface, winner: "VisualPlayer") -> None:
        """Initialize the winner display.

        Args:
            screen: The pygame display surface.
            winner: The winning player object.
        """
        self.screen = screen
        self.winner = winner
        self.winner_name = winner.name

        # Load and scale the winner's image - scale proportionally
        png_path = resource_path(winner.name + ".png", players)
        img = pygame.image.load(png_path)
        image_size = int(APP_HEIGHT * 0.24)  # 24% of screen height
        self.winner_image = pygame.transform.scale(img, (image_size, image_size))

        # Button properties - scale proportionally
        self.button_width = int(APP_WIDTH * 0.22)  # 22% of screen width
        self.button_height = int(APP_HEIGHT * 0.07)  # 7% of screen height
        self.button_spacing = int(APP_HEIGHT * 0.024)  # 2.4% of screen height

        # Calculate button positions
        dialog_width = int(APP_WIDTH * 0.52)  # 52% of screen width
        dialog_height = int(APP_HEIGHT * 0.66)  # 66% of screen height
        dialog_x = int((APP_WIDTH - dialog_width) // 2)
        dialog_y = int((APP_HEIGHT - dialog_height) // 2)

        # New Game button
        self.new_game_button_rect = pygame.Rect(
            dialog_x + (dialog_width - self.button_width) // 2,
            dialog_y + dialog_height - int(APP_HEIGHT * 0.17),
            self.button_width,
            self.button_height,
        )

        # Quit button
        self.quit_button_rect = pygame.Rect(
            dialog_x + (dialog_width - self.button_width) // 2,
            dialog_y
            + dialog_height
            - int(APP_HEIGHT * 0.17)
            + self.button_height
            + self.button_spacing,
            self.button_width,
            self.button_height,
        )

        # Hover states
        self.new_game_hovered = False
        self.quit_hovered = False

    def show(self) -> str:
        """Show the winner display and wait for user to click a button.

        This displays a congratulations message and waits for the user to
        click either "Start New Game" or "Quit".

        Returns:
            "new_game" if user clicked Start New Game, "quit" if user clicked Quit.
        """
        clock = pygame.time.Clock()

        while True:
            # Handle events
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    return "quit"
                if event.type == pygame.MOUSEBUTTONDOWN:
                    mouse_x, mouse_y = pygame.mouse.get_pos()

                    # Check if New Game button was clicked
                    if self.new_game_button_rect.collidepoint(mouse_x, mouse_y):
                        return "new_game"

                    # Check if Quit button was clicked
                    if self.quit_button_rect.collidepoint(mouse_x, mouse_y):
                        return "quit"

            # Update hover states
            mouse_x, mouse_y = pygame.mouse.get_pos()
            self.new_game_hovered = self.new_game_button_rect.collidepoint(
                mouse_x, mouse_y
            )
            self.quit_hovered = self.quit_button_rect.collidepoint(mouse_x, mouse_y)

            # Draw
            self._draw()

            # Update display
            pygame.display.flip()
            clock.tick(60)  # 60 FPS

    def _draw(self) -> None:
        """Draw the winner display dialog."""
        # Draw semi-transparent overlay
        overlay = pygame.Surface((int(APP_WIDTH), int(APP_HEIGHT)))
        overlay.set_alpha(220)
        overlay.fill((0, 0, 0))
        self.screen.blit(overlay, (0, 0))

        # Draw dialog background - scale proportionally
        dialog_width = int(APP_WIDTH * 0.52)  # 52% of screen width
        dialog_height = int(APP_HEIGHT * 0.66)  # 66% of screen height
        dialog_x = int((APP_WIDTH - dialog_width) // 2)
        dialog_y = int((APP_HEIGHT - dialog_height) // 2)

        # Draw background with gradient effect (using solid color for simplicity)
        pygame.draw.rect(
            self.screen,
            (20, 60, 20),  # Dark green background
            (dialog_x, dialog_y, dialog_width, dialog_height),
        )

        # Draw dialog border with gold color
        border_width = max(3, int(APP_HEIGHT * 0.006))  # 0.6% of screen height, min 3
        pygame.draw.rect(
            self.screen,
            (255, 215, 0),  # Gold border
            (dialog_x, dialog_y, dialog_width, dialog_height),
            border_width,
        )

        # Draw inner border for extra emphasis
        inner_border_offset = int(APP_HEIGHT * 0.012)  # 1.2% of screen height
        pygame.draw.rect(
            self.screen,
            (200, 200, 100),  # Lighter gold
            (
                dialog_x + inner_border_offset,
                dialog_y + inner_border_offset,
                dialog_width - 2 * inner_border_offset,
                dialog_height - 2 * inner_border_offset,
            ),
            max(2, int(APP_HEIGHT * 0.0024)),  # 0.24% of screen height, min 2
        )

        # Draw "WINNER!" title - scale font size
        title_font_size = int(APP_HEIGHT * 0.115)  # 11.5% of screen height
        title_font = pygame.font.Font(None, title_font_size)
        title_text = title_font.render("WINNER!", ANTI_ALIASING, (255, 215, 0))
        title_rect = title_text.get_rect(
            center=(int(APP_WIDTH // 2), dialog_y + int(APP_HEIGHT * 0.07))
        )
        self.screen.blit(title_text, title_rect)

        # Draw winner image with gold border
        image_size = int(APP_HEIGHT * 0.24)  # 24% of screen height
        image_x = int((APP_WIDTH - image_size) // 2)
        image_y = dialog_y + int(APP_HEIGHT * 0.17)

        # Draw gold border around image
        border_padding = int(APP_HEIGHT * 0.012)  # 1.2% of screen height
        pygame.draw.rect(
            self.screen,
            (255, 215, 0),  # Gold border
            (
                image_x - border_padding,
                image_y - border_padding,
                image_size + 2 * border_padding,
                image_size + 2 * border_padding,
            ),
            border_width,
        )

        # Draw the winner's image
        self.screen.blit(self.winner_image, (image_x, image_y))

        # Draw winner name below image - scale font size
        name_font_size = int(APP_HEIGHT * 0.067)  # 6.7% of screen height
        name_font = pygame.font.Font(None, name_font_size)
        name_text = name_font.render(self.winner_name, ANTI_ALIASING, (255, 255, 255))
        name_rect = name_text.get_rect(
            center=(int(APP_WIDTH // 2), image_y + image_size + int(APP_HEIGHT * 0.048))
        )
        self.screen.blit(name_text, name_rect)

        # Draw buttons
        self._draw_button(
            self.new_game_button_rect,
            "Start New Game",
            hovered=self.new_game_hovered,
            normal_color=(50, 150, 50),  # Green
            hover_color=(70, 200, 70),  # Lighter green on hover
        )

        self._draw_button(
            self.quit_button_rect,
            "Quit",
            hovered=self.quit_hovered,
            normal_color=(150, 50, 50),  # Red
            hover_color=(200, 70, 70),  # Lighter red on hover
        )

    def _draw_button(
        self,
        rect: pygame.Rect,
        text: str,
        *,
        hovered: bool,
        normal_color: tuple[int, int, int],
        hover_color: tuple[int, int, int],
    ) -> None:
        """Draw a button with hover effect.

        Args:
            rect: The button rectangle.
            text: The button text.
            hovered: Whether the button is hovered.
            normal_color: The normal button color.
            hover_color: The hover button color.
        """
        # Choose color based on hover state
        color = hover_color if hovered else normal_color

        # Draw button background - scale border radius
        border_radius = int(APP_HEIGHT * 0.012)  # 1.2% of screen height
        pygame.draw.rect(self.screen, color, rect, border_radius=border_radius)

        # Draw button border
        border_color = (255, 215, 0) if hovered else (200, 200, 100)
        border_width = max(2, int(APP_HEIGHT * 0.0036))  # 0.36% of screen height, min 2
        pygame.draw.rect(
            self.screen, border_color, rect, border_width, border_radius=border_radius
        )

        # Draw button text - scale font size
        button_font_size = int(APP_HEIGHT * 0.058)  # 5.8% of screen height
        button_font = pygame.font.Font(None, button_font_size)
        button_text = button_font.render(text, ANTI_ALIASING, (255, 255, 255))
        text_rect = button_text.get_rect(center=rect.center)
        self.screen.blit(button_text, text_rect)
__init__(screen, winner)

Initialize the winner display.

Parameters:

Name Type Description Default
screen Surface

The pygame display surface.

required
winner VisualPlayer

The winning player object.

required
Source code in notty/src/visual/winner_display.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def __init__(self, screen: pygame.Surface, winner: "VisualPlayer") -> None:
    """Initialize the winner display.

    Args:
        screen: The pygame display surface.
        winner: The winning player object.
    """
    self.screen = screen
    self.winner = winner
    self.winner_name = winner.name

    # Load and scale the winner's image - scale proportionally
    png_path = resource_path(winner.name + ".png", players)
    img = pygame.image.load(png_path)
    image_size = int(APP_HEIGHT * 0.24)  # 24% of screen height
    self.winner_image = pygame.transform.scale(img, (image_size, image_size))

    # Button properties - scale proportionally
    self.button_width = int(APP_WIDTH * 0.22)  # 22% of screen width
    self.button_height = int(APP_HEIGHT * 0.07)  # 7% of screen height
    self.button_spacing = int(APP_HEIGHT * 0.024)  # 2.4% of screen height

    # Calculate button positions
    dialog_width = int(APP_WIDTH * 0.52)  # 52% of screen width
    dialog_height = int(APP_HEIGHT * 0.66)  # 66% of screen height
    dialog_x = int((APP_WIDTH - dialog_width) // 2)
    dialog_y = int((APP_HEIGHT - dialog_height) // 2)

    # New Game button
    self.new_game_button_rect = pygame.Rect(
        dialog_x + (dialog_width - self.button_width) // 2,
        dialog_y + dialog_height - int(APP_HEIGHT * 0.17),
        self.button_width,
        self.button_height,
    )

    # Quit button
    self.quit_button_rect = pygame.Rect(
        dialog_x + (dialog_width - self.button_width) // 2,
        dialog_y
        + dialog_height
        - int(APP_HEIGHT * 0.17)
        + self.button_height
        + self.button_spacing,
        self.button_width,
        self.button_height,
    )

    # Hover states
    self.new_game_hovered = False
    self.quit_hovered = False
_draw()

Draw the winner display dialog.

Source code in notty/src/visual/winner_display.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def _draw(self) -> None:
    """Draw the winner display dialog."""
    # Draw semi-transparent overlay
    overlay = pygame.Surface((int(APP_WIDTH), int(APP_HEIGHT)))
    overlay.set_alpha(220)
    overlay.fill((0, 0, 0))
    self.screen.blit(overlay, (0, 0))

    # Draw dialog background - scale proportionally
    dialog_width = int(APP_WIDTH * 0.52)  # 52% of screen width
    dialog_height = int(APP_HEIGHT * 0.66)  # 66% of screen height
    dialog_x = int((APP_WIDTH - dialog_width) // 2)
    dialog_y = int((APP_HEIGHT - dialog_height) // 2)

    # Draw background with gradient effect (using solid color for simplicity)
    pygame.draw.rect(
        self.screen,
        (20, 60, 20),  # Dark green background
        (dialog_x, dialog_y, dialog_width, dialog_height),
    )

    # Draw dialog border with gold color
    border_width = max(3, int(APP_HEIGHT * 0.006))  # 0.6% of screen height, min 3
    pygame.draw.rect(
        self.screen,
        (255, 215, 0),  # Gold border
        (dialog_x, dialog_y, dialog_width, dialog_height),
        border_width,
    )

    # Draw inner border for extra emphasis
    inner_border_offset = int(APP_HEIGHT * 0.012)  # 1.2% of screen height
    pygame.draw.rect(
        self.screen,
        (200, 200, 100),  # Lighter gold
        (
            dialog_x + inner_border_offset,
            dialog_y + inner_border_offset,
            dialog_width - 2 * inner_border_offset,
            dialog_height - 2 * inner_border_offset,
        ),
        max(2, int(APP_HEIGHT * 0.0024)),  # 0.24% of screen height, min 2
    )

    # Draw "WINNER!" title - scale font size
    title_font_size = int(APP_HEIGHT * 0.115)  # 11.5% of screen height
    title_font = pygame.font.Font(None, title_font_size)
    title_text = title_font.render("WINNER!", ANTI_ALIASING, (255, 215, 0))
    title_rect = title_text.get_rect(
        center=(int(APP_WIDTH // 2), dialog_y + int(APP_HEIGHT * 0.07))
    )
    self.screen.blit(title_text, title_rect)

    # Draw winner image with gold border
    image_size = int(APP_HEIGHT * 0.24)  # 24% of screen height
    image_x = int((APP_WIDTH - image_size) // 2)
    image_y = dialog_y + int(APP_HEIGHT * 0.17)

    # Draw gold border around image
    border_padding = int(APP_HEIGHT * 0.012)  # 1.2% of screen height
    pygame.draw.rect(
        self.screen,
        (255, 215, 0),  # Gold border
        (
            image_x - border_padding,
            image_y - border_padding,
            image_size + 2 * border_padding,
            image_size + 2 * border_padding,
        ),
        border_width,
    )

    # Draw the winner's image
    self.screen.blit(self.winner_image, (image_x, image_y))

    # Draw winner name below image - scale font size
    name_font_size = int(APP_HEIGHT * 0.067)  # 6.7% of screen height
    name_font = pygame.font.Font(None, name_font_size)
    name_text = name_font.render(self.winner_name, ANTI_ALIASING, (255, 255, 255))
    name_rect = name_text.get_rect(
        center=(int(APP_WIDTH // 2), image_y + image_size + int(APP_HEIGHT * 0.048))
    )
    self.screen.blit(name_text, name_rect)

    # Draw buttons
    self._draw_button(
        self.new_game_button_rect,
        "Start New Game",
        hovered=self.new_game_hovered,
        normal_color=(50, 150, 50),  # Green
        hover_color=(70, 200, 70),  # Lighter green on hover
    )

    self._draw_button(
        self.quit_button_rect,
        "Quit",
        hovered=self.quit_hovered,
        normal_color=(150, 50, 50),  # Red
        hover_color=(200, 70, 70),  # Lighter red on hover
    )
_draw_button(rect, text, *, hovered, normal_color, hover_color)

Draw a button with hover effect.

Parameters:

Name Type Description Default
rect Rect

The button rectangle.

required
text str

The button text.

required
hovered bool

Whether the button is hovered.

required
normal_color tuple[int, int, int]

The normal button color.

required
hover_color tuple[int, int, int]

The hover button color.

required
Source code in notty/src/visual/winner_display.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
def _draw_button(
    self,
    rect: pygame.Rect,
    text: str,
    *,
    hovered: bool,
    normal_color: tuple[int, int, int],
    hover_color: tuple[int, int, int],
) -> None:
    """Draw a button with hover effect.

    Args:
        rect: The button rectangle.
        text: The button text.
        hovered: Whether the button is hovered.
        normal_color: The normal button color.
        hover_color: The hover button color.
    """
    # Choose color based on hover state
    color = hover_color if hovered else normal_color

    # Draw button background - scale border radius
    border_radius = int(APP_HEIGHT * 0.012)  # 1.2% of screen height
    pygame.draw.rect(self.screen, color, rect, border_radius=border_radius)

    # Draw button border
    border_color = (255, 215, 0) if hovered else (200, 200, 100)
    border_width = max(2, int(APP_HEIGHT * 0.0036))  # 0.36% of screen height, min 2
    pygame.draw.rect(
        self.screen, border_color, rect, border_width, border_radius=border_radius
    )

    # Draw button text - scale font size
    button_font_size = int(APP_HEIGHT * 0.058)  # 5.8% of screen height
    button_font = pygame.font.Font(None, button_font_size)
    button_text = button_font.render(text, ANTI_ALIASING, (255, 255, 255))
    text_rect = button_text.get_rect(center=rect.center)
    self.screen.blit(button_text, text_rect)
show()

Show the winner display and wait for user to click a button.

This displays a congratulations message and waits for the user to click either "Start New Game" or "Quit".

Returns:

Type Description
str

"new_game" if user clicked Start New Game, "quit" if user clicked Quit.

Source code in notty/src/visual/winner_display.py
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def show(self) -> str:
    """Show the winner display and wait for user to click a button.

    This displays a congratulations message and waits for the user to
    click either "Start New Game" or "Quit".

    Returns:
        "new_game" if user clicked Start New Game, "quit" if user clicked Quit.
    """
    clock = pygame.time.Clock()

    while True:
        # Handle events
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return "quit"
            if event.type == pygame.MOUSEBUTTONDOWN:
                mouse_x, mouse_y = pygame.mouse.get_pos()

                # Check if New Game button was clicked
                if self.new_game_button_rect.collidepoint(mouse_x, mouse_y):
                    return "new_game"

                # Check if Quit button was clicked
                if self.quit_button_rect.collidepoint(mouse_x, mouse_y):
                    return "quit"

        # Update hover states
        mouse_x, mouse_y = pygame.mouse.get_pos()
        self.new_game_hovered = self.new_game_button_rect.collidepoint(
            mouse_x, mouse_y
        )
        self.quit_hovered = self.quit_button_rect.collidepoint(mouse_x, mouse_y)

        # Draw
        self._draw()

        # Update display
        pygame.display.flip()
        clock.tick(60)  # 60 FPS