1
\$\begingroup\$

So I've decided to practice I will turn all my scripts etc into GUI apps. So my 36 loc guess the number game turned into 136 loc. It's not the best looking, but it does work and on hard it's actually hard to beat computer sometimes. What are your thoughts guys? After finishing it I am not sure if my approach was right. Thank you for any comments and advice. No offence if they will be harsh ;)

screenshot

import math
import tkinter as tk
import random
from tkinter import Frame, PhotoImage, Radiobutton, StringVar, ttk
from typing import Any
from math import ceil


class Game(tk.Tk):
    def __init__(self) -> None:
        super().__init__()

        self.geometry('400x440+400+300')
        self.title('Guess The Number')
        self.iconphoto(True, PhotoImage(file='icon.png'))

        self.level_var = StringVar(value='medium')
        self.player_var = StringVar(value='Player')
        self.number_var = StringVar(value='0')
        self.player_attempts = 0
        self.computer_attempts = 0
        self.turn = StringVar(value='0')
        self.winner = ''

        self.switch_frame(StartFrame)

    def switch_frame(self, frameClass: Any) -> None:
        new_frame: Frame = frameClass(self)
        self._frame = new_frame
        self._frame.place(x=0, y=0, width=400, height=440)
    
    # function to ensure user input is actually a number nothing else.
    def is_number(self, valueToCheck: str) -> int:
        try:
            return int(valueToCheck)
        except ValueError:
            return 0

    # difficulty level scope
    def game_level(self, level: str) -> int:
        if level == 'low':
            return 10
        elif level == 'medium':
            return 100
        else:
            return 1000
    
    def new_game(self) -> None:
        self.player_attempts = 0
        self.computer_attempts = 0
        self.turn.set('0')
        self.winner = ''

       
class StartFrame(tk.Frame):
    def __init__(self, master: Game) -> None:
        tk.Frame.__init__(self, master)
        tk.Frame.configure(self, bg='#9aaeb6')
        main_font = ('Arial', 12, 'bold')
        secondary_font = ('Arial', 10)
        
        main_label = ttk.Label(self, text='Game Settings', anchor='center', background='#eee', font=main_font)
        main_label.place(x=10, y=10, width=380, height=30)

        level_label = ttk.Label(self, text='Difficulty Level', anchor='center', background='#eee', font=secondary_font)
        level_label.place(x=10, y=70, width=380, height=30)

        level_1 = Radiobutton(self, text='Low', variable=master.level_var, value='low')
        level_1.place(x=50, y=110, height=30, width=80)

        level_2 = Radiobutton(self, text='Medium', variable=master.level_var, value='medium')
        level_2.place(x=160, y=110, height=30, width=80)

        level_3 = Radiobutton(self, text='Hard', variable=master.level_var, value='hard')
        level_3.place(x=270, y=110, height=30, width=80)

        player_label = ttk.Label(self, text='Who will start the game? You or Computer?', anchor='center', background='#eee', font=secondary_font)
        player_label.place(x=10, y=150, width=380, height=30)

        player_1 = Radiobutton(self, text='Player', variable=master.player_var, value='Player')
        player_1.place(x=100, y=190, height=30, width=80)

        player_2 = Radiobutton(self, text='Computer', variable=master.player_var, value='Computer')
        player_2.place(x=220, y=190, height=30, width=80)

        number_label = ttk.Label(self, text='What is your secret number?', anchor='center', background='#eee', font=secondary_font)
        number_label.place(x=10, y=230, width=380, height=30)

        number_entry = ttk.Entry(self, textvariable=master.number_var, justify='center')
        number_entry.place(x=100, y=270, width=200, height=40)

        play_button = ttk.Button(self, text='PLAY', command=lambda: master.switch_frame(GameFrame))
        play_button.place(x=100, y=350, width=200, height=60)


class GameFrame(tk.Frame):
    def __init__(self, master: Game) -> None:
        tk.Frame.__init__(self, master)
        tk.Frame.configure(self, bg='#9aaeb6')

        main_font = ('Arial', 12, 'bold')
        secondary_font = ('Arial', 10)

        self.com_out = StringVar(value='Let\'s start.')

        if master.player_var.get() == 'Computer':
            master.turn.set('1')
            
        self.player_secret_number = master.is_number(master.number_var.get())
        game_lvl = master.game_level(master.level_var.get())
        self.computer_secret_number = random.randint(0, game_lvl)

        self.computer_low = 0
        self.computer_high = game_lvl

        # ensure player number can't be higher than choosen difficulty lvl scope
        if self.player_secret_number > game_lvl:
            self.player_secret_number = random.randint(0, game_lvl)

        main_label = ttk.Label(self, text='Game Time', anchor='center', background='#eee', font=main_font)
        main_label.place(x=10, y=10, width=380, height=30)

        turn_label = ttk.Label(self, text='Turn:', anchor='center', background='#eee', font=main_font)
        turn_label.place(x=10, y=60, width=190, height=30)

        self.turn_display = StringVar(value='1')
        turn_label_val = ttk.Label(self, textvariable=self.turn_display, anchor='center', background='#eee', font=main_font)
        turn_label_val.place(x=200, y=60, width=190, height=30)

        player_label = ttk.Label(self, text='Player', anchor='center', background='#eee', font=main_font)
        player_label.place(x=50, y=105, width=100, height=30)

        computer_label = ttk.Label(self, text='Computer', anchor='center', background='#eee', font=main_font)
        computer_label.place(x=250, y=105, width=100, height=30)

        command_out = ttk.Label(self, textvariable=self.com_out, anchor='center', font=secondary_font)
        command_out.place(x=115, y=180, height=40, width=180)

        self.player_guess = StringVar()
        player_guess_entry = ttk.Entry(self, textvariable=self.player_guess, justify='center', font=main_font)
        player_guess_entry.place(x=160, y=250, height=40, width=90)

        guess_button = ttk.Button(self, text='Guess', command=lambda: self.play(master=master))
        guess_button.place(x=160, y=300, height=60, width=90)

    def play(self, master: Game) -> None:
        if int(master.turn.get()) % 2 != 0:
            computer_choice = random.randint(self.computer_low, self.computer_high)
            if computer_choice == self.player_secret_number:
                master.winner = 'computer'
                master.switch_frame(WinnerFrame)
            else:
                if computer_choice > self.player_secret_number:
                    self.computer_high = computer_choice
                else:
                    self.computer_low = computer_choice

            master.computer_attempts += 1
            master.turn.set(str(int((master.turn.get())) + 1))
            self.turn_display.set(str(math.ceil(int(master.turn.get()) / 2)))
        else:
            try:    
                player_choice = int(self.player_guess.get())
                if player_choice == self.computer_secret_number:
                    master.winner = 'player'
                    master.switch_frame(WinnerFrame)
                else:
                    if player_choice > self.computer_secret_number:
                        self.com_out.set('Number too high.')
                    else:
                        self.com_out.set('Number too low.')
            except ValueError:
                pass
            
            master.player_attempts += 1
            master.turn.set(str(int((master.turn.get())) + 1))
            self.player_guess.set('0')
            self.turn_display.set(str(math.ceil(int(master.turn.get()) / 2)))
            self.play(master)


class WinnerFrame(tk.Frame):
    def __init__(self, master: Game) -> None:
        tk.Frame.__init__(self, master)
        main_font = ('Arial', 12, 'bold')
        
        def winner_looser():
            if master.winner == 'computer':
                return 'red'
            else:
                return 'green'

        self.configure(background=winner_looser())
        main_label = ttk.Label(self, text='Do you want to play again?', anchor='center', background='#eee', font=main_font)
        main_label.place(x=10, y=120, width=380, height=30)

        yes_button = ttk.Button(self, text='YES', command=lambda: [master.switch_frame(StartFrame),
                                                                    master.new_game()])
        yes_button.place(x=100, y=180, width=90, height=70)

        no_button = ttk.Button(self, text='NO', command=master.destroy)
        no_button.place(x=200, y=180, width=90, height=70)
        
    def attempt_result() -> str:
            if master.winner == 'computer':
                return f'{master.computer_attempts} moves for Computer to beat you.'
            else:
                return f'{master.player_attempts} moves, all you needed to win.'
        
        result_label = ttk.Label(self, text=attempt_result(), anchor='center')
        result_label.place(x=10, y=270, width=380)
if __name__ == '__main__':
    Game().mainloop()
\$\endgroup\$

1 Answer 1

1
\$\begingroup\$
  • You're not using math, so delete that import
  • is_number has nothing to do with the class, so move it to global scope. Also, this can be simplified with str.isnumeric if the function did what it's named to do. However, it doesn't do what it's named to do - it doesn't check whether a string is numeric; it attempts to parse an integer; so a more appropriate name would be try_parse_int. Finally: returning 0 on failure is not a good idea. What if valueToCheck (which should be value_to_check) is itself '0'? How can you distinguish between that and a failure?
  • It's unlikely that StartFrame should exist as a class. It has no members, doesn't access the members of its parent, and doesn't override anything. It can just be a function. Better, keep it and others as classes, but don't inherit; instantiate the frame or window as appropriate ("has-a", not "is-a").
  • 'Let\'s start.' would not require an escape if enclosed in double quotes.
  • GameFrame has poor separation of concerns and bakes in a bunch of game logic when all it should do is display.
  • Typo: looser -> loser
  • attempt_result is missing a self, and refers to a master variable that's undefined. The initialization of result_label is done in the static namespace, which is not appropriate.
  • Consider representing player state in a class - one for both the user and computer - and differentiating behaviour using polymorphism. This is not the only way to do things, but it's one way.
  • You only have StringVars. Sometimes IntVar or BooleanVar are called for.
  • You can simplify your difficulty logic by using 10**x where x is a difficulty between 1 and 3.
  • Move your main and secondary fonts to global constants for reuse.
  • Many of the declarations that you make for tk variables don't actually need to keep references, locally or otherwise.
  • If you want your computer to be harder to beat, call into bisect.
  • Consider using a spinbox instead of an unconstrained entry box for the guess, so that lower and upper limits are automatically enforced.
  • Consider telling the user what their minimum and maximum guess is permitted to be.
  • For quality of life, after the user has clicked the Guess button, re-select the guess entry. I haven't shown this below.
  • You have a mix of qualified and de-qualified tk imports. Choose one (probably the qualified version with no from).
  • Don't repeat your geometric references (the 400 pixels) - instead, you can format the geometry string based on integer members of your view class.
  • Give an owner and name= to all of your Var instances.
  • Where possible, avoid writing "master".

Suggested

I messed around with coroutines a little (see blocked_on_user_coro) - they're a convenient way to "pause" code, for example when waiting for player input. This also relies on function reference hooks to enforce loose coupling.

import tkinter as tk
import random
from itertools import count
from tkinter import ttk
from typing import Callable, Optional, ClassVar, Tuple

MAIN_FONT = ('Arial', 12, 'bold')
SECONDARY_FONT = ('Arial', 10)


class Player:
    NAME: ClassVar[str]
    INTERACTIVE: ClassVar[bool]
    WIN_SUFFIX: ClassVar[str]

    def __init__(self, secret: int) -> None:
        self.attempts = 0
        # The secret the player is guessing about, not the secret the other
        # player has chosen
        self.secret = secret

    def play(self) -> int:
        self.attempts += 1
        guess = self.guess()
        delta = guess - self.secret
        self.strategise(guess, delta)
        return delta

    def guess(self) -> int:
        raise NotImplementedError()

    def strategise(self, guess: int, delta: int) -> None:
        pass

    @property
    def result(self) -> str:
        return f'{self.attempts}{self.WIN_SUFFIX}'


class User(Player):
    INTERACTIVE = True
    NAME = 'Player'
    WIN_SUFFIX = ' moves, all you needed to win.'

    def __init__(self, max_number: int, get_guess: Callable[[], int]):
        super().__init__(secret=random.randrange(max_number))
        self.guess = get_guess


class Computer(Player):
    INTERACTIVE = False
    NAME = 'Computer'
    WIN_SUFFIX = ' moves for Computer to beat you.'

    def __init__(self, max_number: int, secret: int) -> None:
        super().__init__(secret)
        self.lower, self.upper = 0, max_number

    def guess(self) -> int:
        guess = random.randrange(start=self.lower, stop=self.upper)
        # print(f'{self.NAME}: {self.lower} <= {guess} < {self.upper}')
        return guess

    def strategise(self, guess: int, delta: int) -> None:
        if delta > 0:  # too high
            self.upper = guess
        elif delta < 0:  # too low
            self.lower = guess


class Game:
    def __init__(
        self, max_number: int, user_secret: int, user_goes_first: bool,
        parent: tk.Tk, win_hook: Callable[[Player], None],
    ) -> None:
        self.view = GameView(parent, max_number, self.user_played)
        self.players: Tuple[Player, ...] = (
            User(max_number=max_number, get_guess=self.view.player_guess_var.get),
            Computer(max_number=max_number, secret=user_secret),
        )
        if not user_goes_first:
            self.players = self.players[::-1]
        self.win_hook = win_hook
        self.blocked_on_user = None

    def start(self) -> None:
        self.blocked_on_user = self.blocked_on_user_coro()
        self.user_played()

    def user_played(self) -> None:
        try:
            next(self.blocked_on_user)
        except StopIteration:
            pass

    def blocked_on_user_coro(self):
        for turn_index in count():
            self.view.show_turn(turn_index)

            for player in self.players:
                if player.INTERACTIVE:
                    yield  # wait for a user to play
                if self.do_turn(player):
                    return

    def do_turn(self, player: Player) -> bool:
        delta = player.play()
        if delta == 0:
            self.win_hook(player)
            return True

        if player.INTERACTIVE:
            if delta < 0:
                desc = 'low'
            else:
                desc = 'high'
            self.view.show_command(f'Number too {desc}.')
        return False


class GameProgram:
    def __init__(self):
        self.parent_view = ParentView()

    def run(self) -> None:
        def play():
            game = Game(
                max_number=start_view.max_number,
                user_secret=start_view.number_var.get(),
                user_goes_first=start_view.player_goes_first_var.get(),
                parent=self.parent_view.window,
                win_hook=self.win,
            )
            self.parent_view.switch_frame(game.view.frame)
            game.start()

        start_view = StartView(self.parent_view.window, play)
        self.parent_view.switch_frame(start_view.frame)
        self.parent_view.window.mainloop()

    def win(self, winner: Player) -> None:
        winner_view = WinnerView(
            parent=self.parent_view.window,
            user_won=winner.INTERACTIVE,
            result=winner.result,
            restart_hook=self.run,
        )
        self.parent_view.switch_frame(winner_view.frame)


class ParentView:
    def __init__(self) -> None:
        self.window = tk.Tk()
        self.width = 400
        self.height = 440
        self.window.geometry(f'{self.width}x{self.height}+400+300')
        self.window.title('Guess The Number')
        # self.parent.iconphoto(True, PhotoImage(file='icon.png'))

        self.child: Optional[tk.Frame] = None

    def switch_frame(self, child: tk.Frame) -> None:
        if self.child is not None:
            self.child.destroy()

        child.place(
            x=0, width=self.width,
            y=0, height=self.height,
        )
        self.child = child


class StartView:
    def __init__(self, parent: tk.Tk, done_hook: Callable[[], None]):
        self.frame = tk.Frame(parent, background='#9aaeb6')

        self.level_var = tk.IntVar(self.frame, name='level', value=1)
        self.player_goes_first_var = tk.BooleanVar(self.frame, name='player_goes_first', value=True)
        self.number_var = tk.IntVar(self.frame, name='number', value=0)

        ttk.Label(
            self.frame, text='Game Settings', anchor='center',
            background='#eee', font=MAIN_FONT,
        ).place(x=10, y=10, width=380, height=30)

        ttk.Label(
            self.frame, text='Difficulty Level', anchor='center',
            background='#eee', font=SECONDARY_FONT,
        ).place(x=10, y=70, width=380, height=30)
        tk.Radiobutton(
            self.frame, text='Low', variable=self.level_var, value=1,
        ).place(x=50, y=110, height=30, width=80)
        tk.Radiobutton(
            self.frame, text='Medium', variable=self.level_var, value=2,
        ).place(x=160, y=110, height=30, width=80)
        tk.Radiobutton(
            self.frame, text='Hard', variable=self.level_var, value=3,
        ).place(x=270, y=110, height=30, width=80)

        ttk.Label(
            self.frame, text='Who will start the game? You or Computer?',
            anchor='center', background='#eee', font=SECONDARY_FONT,
        ).place(x=10, y=150, width=380, height=30)
        tk.Radiobutton(
            self.frame, text='Player', variable=self.player_goes_first_var, value=True,
        ).place(x=100, y=190, height=30, width=80)
        tk.Radiobutton(
            self.frame, text='Computer', variable=self.player_goes_first_var, value=False,
        ).place(x=220, y=190, height=30, width=80)

        ttk.Label(
            self.frame, text='What is your secret number?', anchor='center',
            background='#eee', font=SECONDARY_FONT,
        ).place(x=10, y=230, width=380, height=30)

        ttk.Entry(
            self.frame, textvariable=self.number_var, justify='center',
        ).place(x=100, y=270, width=200, height=40)

        ttk.Button(
            self.frame, text='PLAY', command=self.play,
        ).place(x=100, y=350, width=200, height=60)

        self.done_hook = done_hook

    @property
    def max_number(self) -> int:
        return 10**self.level_var.get()

    @property
    def is_valid(self) -> bool:
        try:
            return 0 <= self.number_var.get() < self.max_number
        except tk.TclError:
            return False

    def play(self) -> None:
        if self.is_valid:
            self.done_hook()


class GameView:
    def __init__(self, parent: tk.Tk, max_number: int, play_hook: Callable[[], None]) -> None:
        self.frame = tk.Frame(parent, background='#9aaeb6')

        self.make_label(text='Game Time').place(x=10, y=10, width=380, height=30)

        self.turn_var = tk.IntVar(self.frame, name='turn', value=0)
        self.make_label(text='Turn:').place(x=10, y=60, width=190, height=30)
        self.make_label(textvariable=self.turn_var).place(x=200, y=60, width=190, height=30)

        self.make_label(text='Player').place(x=50, y=105, width=100, height=30)
        self.make_label(text='Computer').place(x=250, y=105, width=100, height=30)

        self.command_var = tk.StringVar(self.frame, name='command', value="Let's start.")
        self.make_label(
            textvariable=self.command_var, font=SECONDARY_FONT,
        ).place(x=115, y=180, height=40, width=180)

        self.player_guess_var = tk.IntVar(self.frame, name='player_guess')
        ttk.Spinbox(
            self.frame, justify='center', font=MAIN_FONT,
            textvariable=self.player_guess_var, from_=0, to=max_number-1,
        ).place(x=160, y=250, height=40, width=90)

        ttk.Button(
            self.frame, text='Guess', command=play_hook,
        ).place(x=160, y=300, height=60, width=90)

    def show_command(self, command: str) -> None:
        self.command_var.set(command)

    def show_turn(self, index: int) -> None:
        self.turn_var.set(index)

    def make_label(self, **kwargs) -> tk.Label:
        label_kwargs = {
            'master': self.frame,
            'anchor': 'center',
            'background': '#eee',
            'font': MAIN_FONT,
            **kwargs,
        }
        return ttk.Label(**label_kwargs)


class WinnerView:
    def __init__(
        self, parent: tk.Tk, user_won: bool, result: str, restart_hook: Callable[[], None],
    ) -> None:
        background = 'green' if user_won else 'red'
        self.frame = tk.Frame(parent, background=background)

        ttk.Label(
            self.frame, text='Do you want to play again?',
            anchor='center', background='#eee', font=MAIN_FONT,
        ).place(x=10, y=120, width=380, height=30)

        ttk.Button(self.frame, text='YES', command=restart_hook,).place(x=100, y=180, width=90, height=70)
        ttk.Button(self.frame, text='NO', command=parent.destroy).place(x=200, y=180, width=90, height=70)
        ttk.Label(self.frame, text=result, anchor='center').place(x=10, y=270, width=380)


if __name__ == '__main__':
    GameProgram().run()
\$\endgroup\$
1
  • 1
    \$\begingroup\$ Thank you very much for your review. Corrected typo and removed math straight away. \$\endgroup\$
    – Jakub
    Oct 20, 2021 at 19:58

Your Answer

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

Not the answer you're looking for? Browse other questions tagged or ask your own question.