用 Tkinter 实现简单的 15 puzzle

116 阅读6分钟

背景

TkDocs tutorial 里介绍了 Tkinter\text{Tkinter},其中有 A First (Real) Example 一文,这篇文章里有一个使用 Tkinter\text{Tkinter} 生成图形化界面的简单例子。我想在那篇文章的基础上实战一下,于是想到可以实现简单的 15 puzzle\text{15 puzzle}15 puzzleWolfram 中关于该游戏的介绍。在 这个链接 里可以看到 15 puzzle\text{15 puzzle} 的示例效果。

正文

需要解决哪些问题

整体布局

整体布局的草图如下 ⬇️ (有些细节并不准确,这里只是展示一下大意)

image.png

一共需要 22frame\text{frame} ⬇️

  • 靠上的 frame\text{frame} 用于展示所有按钮,其中包括
    • 1515 个数字按钮
    • 11 个表示空位置的按钮
  • 靠下的 frame\text{frame} 用于展示提示信息

如何生成最终局面?

在最终局面里,所有数字按钮都在正确的位置上 ⬇️

image.png

为了方便后续访问,我们可以把各个 button\text{button} 保存在 button_dict\text{button\_dict} 变量里。相关的代码如下 ⬇️

n = 4
button_dict = {}
empty_button_position = (n - 1, n - 1)
click_cnt = 0

def to_original_text(row, col):
    if (row, col) == (n - 1, n - 1):
        return ''
    return str(row * n + col + 1)

def add_button(row, col):
    text = to_original_text(row, col)
    button = ttk.Button(mainframe, text=text, command=lambda: button_command((row, col)))
    button.grid(column=col, row=row, sticky='WE')
    button_dict[(row, col)] = button

def initialize_buttons():
    for r in range(n):
        for c in range(n):
            add_button(r, c)
  • initialize_buttons\text{initialize\_buttons} 方法负责将各个按钮放在正确的位置上
  • add_button\text{add\_button} 方法负责在指定的位置 (row, col)\text{(row, col)} 上放置一个对应的按钮
  • to_original_text\text{to\_original\_text} 方法负责生成指定位置的按钮对应的文本

如何生成随机开局?

我们可以对 1616 个按钮执行 shuffle\text{shuffle} 操作,从而得到随机的开局。但是 15 puzzle 里提到,并非所有的开局都有解。于是我想到,可以从最终局面倒着来(这样可以保证用户看到的局面总是有解的)。具体操作是这样的:最终局面是这样的 ⬇️

image.png

我们可以随机点击与空位置相邻的某个按钮。例如,如果点击 1212 的话,得到的结果会是 ⬇️

image.png

这样操作多次之后,就可以得到一个看似“随机”的开局。一个可能的开局如下 ⬇️

image.png

相关的代码如下 ⬇️

def swap_empty_with_random_neighbor():
    row, col = empty_button_position
    while True:
        delta_row, delta_col = random.choice(delta_position_candidates)
        if not is_inside_board(row + delta_row, col + delta_col):
            continue
        normal_button_position = (row + delta_row, col + delta_col)
        swap_empty_button(normal_button_position)
        break

def shuffle_buttons():
    while True:
        for _ in range(100):
            swap_empty_with_random_neighbor()
        if not all_buttons_at_original_position():
            break
    update_message(False)
    
  • shuffle_buttons\text{shuffle\_buttons} 方法负责打乱按钮的位置
  • swap_empty_with_random_neighbor\text{swap\_empty\_with\_random\_neighbor} 方法负责将表示空位置的按钮与一个随机的邻居(邻居指的是它上下左右相邻位置的按钮)交换位置

如何交换两个相邻的 button\text{button}?

我们并不需要真的交换两个 button\text{button} 的指针或者引用。只要交换两个 button\text{button}text\text{text},就会造成 “这两个 button\text{button} 被交换了” 的错觉。相关的代码如下 ⬇️ 请注意,表示 “空位置” 的那个 button\text{button} 总是会参与 “交换” 操作。

click_cnt = 0
game_started = False
delta_position_candidates = [(-1, 0), (1, 0), (0, -1), (0, 1)]

def update_message(user_win):
    if user_win:
        message_label['text'] = 'You won after clicking %d times' % click_cnt
    else:
        message_label['text'] = 'You have clicked %d times' % click_cnt

def update_click_cnt():
    global click_cnt
    click_cnt += 1
    
def swap_empty_button(normal_button_position):
    normal_button_text = find_button_text(normal_button_position)
    update_button_text(empty_button_position, normal_button_text)
    update_button_text(normal_button_position, '')

    update_empty_button_position(normal_button_position)

    if not game_started:
        return
    update_click_cnt()
    user_win = all_buttons_at_original_position()
    if user_win:
        for button_position in button_dict:
            disable_button(button_position)
    update_message(user_win)

def button_command(normal_button_position):
    row, col = normal_button_position
    for candidate in delta_position_candidates:
        delta_row, delta_col = candidate
        target_button_position = (row + delta_row, col + delta_col)
        if target_button_position != empty_button_position:
            continue
        swap_empty_button(normal_button_position)
  • button_command\text{button\_command} 方法负责将一个按钮 buttonnormalbutton_{normal} 和表示空位置的按钮 buttonemptybutton_{empty} 进行交换(如果这两个按钮相邻的话),这个方法是各个按钮的回调方法
  • swap_empty_button\text{swap\_empty\_button} 方法负责将以下两者进行“交换”(这两个按钮一定是相邻的)
    • 指定位置的按钮
    • 表示空位置的按钮
  • update_click_cnt\text{update\_click\_cnt} 方法用于更新 click_cnt\text{click\_cnt} 这个计数值(它是一个全局变量)
  • update_message\text{update\_message} 方法负责更新展示信息

基于以上分析,再结合 TkDocs tutorial 里介绍的 Tkinter\text{Tkinter} 的知识,可以写出完整的代码(trae 对我完成代码提供了不少帮助,但是重构和关键思路还是要靠自己)

完整的代码

from tkinter import *
from tkinter import ttk
import random

root = Tk()
root.title("15 Puzzle")

mainframe = ttk.Frame(root)
mainframe.grid(column=0, row=0, sticky=W)
mainframe['padding'] = 5

messageframe = ttk.Frame(root)
messageframe.grid(column=0, row=1, sticky=W)
messageframe['padding'] = 5

n = 4
button_dict = {}
empty_button_position = (n - 1, n - 1)
click_cnt = 0
game_started = False

message_label = ttk.Label(messageframe, text='')
message_label.grid(column=0, row=0, sticky=W)

delta_position_candidates = [(-1, 0), (1, 0), (0, -1), (0, 1)]

def is_inside_board(row, col):
    return row >= 0 and row < n and col >= 0 and col < n

def update_button_text(button_position, text):
    button_dict[button_position]['text'] = text

def disable_button(button_position):
    state = ['disabled']
    button_dict[button_position].state(state)

def update_empty_button_position(new_position):
    global empty_button_position
    empty_button_position = new_position

def all_buttons_at_original_position():
    for row in range(n):
        for col in range(n):
            if find_button_text((row, col)) != to_original_text(row, col):
                return False
    return True

def find_button_text(button_position):
    return button_dict[button_position]['text']

def update_click_cnt():
    global click_cnt
    click_cnt += 1

def swap_empty_button(normal_button_position):
    normal_button_text = find_button_text(normal_button_position)
    update_button_text(empty_button_position, normal_button_text)
    update_button_text(normal_button_position, '')

    update_empty_button_position(normal_button_position)

    if not game_started:
        return
    update_click_cnt()
    user_win = all_buttons_at_original_position()
    if user_win:
        for button_position in button_dict:
            disable_button(button_position)
    update_message(user_win)
    
def update_message(user_win):
    if user_win:
        message_label['text'] = 'You won after clicking %d times' % click_cnt
    else:
        message_label['text'] = 'You have clicked %d times' % click_cnt

def button_command(normal_button_position):
    row, col = normal_button_position
    for candidate in delta_position_candidates:
        delta_row, delta_col = candidate
        target_button_position = (row + delta_row, col + delta_col)
        if target_button_position != empty_button_position:
            continue
        swap_empty_button(normal_button_position)

def to_original_text(row, col):
    if (row, col) == (n - 1, n - 1):
        return ''
    return str(row * n + col + 1)

def add_button(row, col):
    text = to_original_text(row, col)
    button = ttk.Button(mainframe, text=text, command=lambda: button_command((row, col)))
    button.grid(column=col, row=row, sticky='WE')
    button_dict[(row, col)] = button

def swap_empty_with_random_neighbor():
    row, col = empty_button_position
    while True:
        delta_row, delta_col = random.choice(delta_position_candidates)
        if not is_inside_board(row + delta_row, col + delta_col):
            continue
        normal_button_position = (row + delta_row, col + delta_col)
        swap_empty_button(normal_button_position)
        break

def shuffle_buttons():
    while True:
        for _ in range(100):
            swap_empty_with_random_neighbor()
        if not all_buttons_at_original_position():
            break
    update_message(False)

def initialize_buttons():
    for r in range(n):
        for c in range(n):
            add_button(r, c)

def initialize_board():
    global game_started
    initialize_buttons()
    shuffle_buttons()
    game_started = True

initialize_board()

root.columnconfigure(0, weight=1)
root.rowconfigure(0, weight=1)
for child in mainframe.winfo_children(): 
    child.grid_configure(padx='1', pady='1')

root.mainloop()

运行效果

请将完整的代码(上文已提供)保存为 fifteen.py。使用下方的命令可以运行 fifteen.py

python3 fifteen.py

运行效果如下图所示 ⬇️ (您在自己电脑上运行该程序得到的开局很可能和下图展示的局面不同)

image.png

对这个局面而言,可以点击的位置是 6,7,106,7,1033 个按钮

image.png

如果点击 66,局面变为 ⬇️ (请注意,“空位置” 和 66 发生了交换)

image.png

我玩了一会儿,终于得到了预期的局面 ⬇️ (一共有 5454 次有效的点击)

image.png

nn 改成 2233

如果觉得 15 puzzle\text{15 puzzle} 玩起来太难了,可以减小代码中 n\text{n} 的值,n\text{n} 的位置如下图所示

image.png

nn 改成 22

n\text{n} 改成 22 之后,示例效果是这样的 ⬇️

image.png

这样就变得简单很多。我点击了 66 次之后,游戏结束 ⬇️

image.png

nn 改成 33

n\text{n} 改成 33 之后,示例效果是这样的 ⬇️

image.png

我点击了 5050 次之后,游戏结束 ⬇️

image.png

其他

整体布局的草图是如何画出来的?

我用了 IntelliJ IDEA (Community Edition)PlantUML 的插件来画那张图。完整的代码如下 ⬇️

@startsalt
{
    {+
        [ 1]|[ 2]|[ 3]|[ 4]
        [ 5]|[ 6]|[ 7]|[ 8]
        [ 9]|[10]|[11]|[12]
        [13]|[14]|[15]|[  ]
    }
    {+
        展示已操作次数
    }
}
@endsalt

参考资料