Skip to content

经典接龙纸牌游戏教程 ​


在本教程中,我们将向你展示如何使用 Flet 框架在 Python 中逐步创建一个著名的 Klondike(克朗代克)接龙纸牌游戏。我们以这个在线游戏为灵感来源:https://www.solitr.com/

本教程面向具备 Python 基础知识和面向对象编程(OOP)基础的初中级 Python 开发者。

你可以在这里查看在线演示。

接龙纸牌游戏

我们将整个游戏的开发过程划分为以下步骤:

在第 2 部分(将在下一个教程中介绍)中,我们将添加带有“开始新游戏”、“查看游戏规则”和“更改游戏设置”等选项的 AppBar 导航栏。

Flet 入门 ​

要使用 Flet 在 Python 中创建多平台应用,你不需要了解 HTML、CSS 或 JavaScript,但确实需要掌握 Python 的基础知识和面向对象编程。

在创建你的第一个 Flet 应用之前,你需要配置开发环境,这需要 Python 3.9 或更高版本以及 flet 包。

安装好 Flet 后,让我们创建一个简单的 Hello World 应用。

创建 hello.py 文件并写入以下内容:

hello.py

python
import flet as ft

def main(page: ft.Page):
    page.add(ft.Text(value="Hello, world!"))

ft.run(main)

运行该应用,你将看到一个包含问候语的新窗口:

hello-world

拖拽卡牌的概念验证应用 ​

为了进行概念验证(POC),我们只需要使用三种控件:

  • Stack - 将用作绝对定位卡牌和卡槽的父控件。
  • GestureDetector - 表示可在 Stack 内移动的卡牌。
  • Container - 表示放置卡牌的卡槽。同时也会用作 GestureDetector 的内容(content)。

我们将概念验证应用拆分为四个简单的步骤,以便每个步骤后你都有一个完整的简短程序可以运行和测试。

步骤 1:拖动卡牌 ​

在此步骤中,我们将创建一个 Stack(纸牌游戏区域)和一个 GestureDetector(纸牌)。然后,卡牌将被添加到 Stack.controls 列表中。我们通过 GestureDetector 的 top 和 left 属性来实现卡牌在 Stack 内的绝对定位。

python
import flet as ft

def main(page: ft.Page):
    def drag(e: ft.DragUpdateEvent):
        ...

    card = ft.GestureDetector(
        on_pan_update=drag,
        left=0,
        top=0,
        content=ft.Container(bgcolor=ft.Colors.GREEN, width=70, height=100),
    )

    page.add(ft.Stack(controls=[card], width=1000, height=500))

ft.run(main)

运行应用,卡牌会被添加到 Stack 中:

drag_and_drop1

为了使卡牌能够被拖动,我们需要更新 drag 方法,该方法会在 GestureDetector 的 on_pan_update 事件触发时被调用(当用户用鼠标拖动卡牌时,每隔 drag_interval 毫秒就会触发一次该事件)。

为了展示卡牌的移动,我们会在每次触发 on_pan_update 事件时,在 drag 回调函数中更新卡牌的 top 和 left 属性。

以下是实现拖动卡牌的最简代码:

python
import flet as ft

# 使用带有 on_pan_update 事件的 GestureDetector 拖动卡牌
# 通过 Stack 实现控件的绝对定位

def main(page: ft.Page):
    def drag(e: ft.DragUpdateEvent):
        e.control.top = max(0, e.control.top + e.local_delta.y)
        e.control.left = max(0, e.control.left + e.local_delta.x)
        e.control.update()

    card = ft.GestureDetector(
        mouse_cursor=ft.MouseCursor.MOVE,
        drag_interval=5,
        on_pan_update=drag,
        left=0,
        top=0,
        content=ft.Container(bgcolor=ft.Colors.GREEN, width=70, height=100),
    )

    page.add(ft.Stack(controls=[card], width=1000, height=500))

ft.run(main)

现在你可以拖动卡牌进行移动了:

drag_and_drop2

注意

控件的任何属性在更新后,必须调用该控件(或其父控件)的 update() 方法,更改才会生效。

步骤 2:放入卡槽或弹回原位 ​

本步骤的目标是:如果卡牌足够接近卡槽,就将其放入卡槽;如果不够近,则将其弹回原位。

drag-and-drop3

让我们创建一个 Container 控件来表示接收卡牌的卡槽:

python
slot = ft.Container(
    width=70,
    height=100,
    left=200,
    top=0,
    border=ft.Border.all(1)
)
page.add(ft.Stack(controls = [slot, card], width=1000, height=500))

当卡牌被释放时,会触发卡牌的 on_pan_end 事件:

python
card = ft.GestureDetector(
    mouse_cursor=ft.MouseCursor.MOVE,
    drag_interval=5,
    on_pan_update=drag,
    on_pan_end=drop,
    left=0,
    top=0,
    content=ft.Container(bgcolor=ft.Colors.GREEN, width=70, height=100),
)

在此事件触发时,我们将调用 drop 方法,检查卡牌是否足够接近卡槽(假设距离卡槽小于 20 像素),如果是,就将其放置在该卡槽:

python
def drop(e: ft.DragEndEvent):
    if (
        abs(e.control.top - slot.top) < 20
        and abs(e.control.left - slot.left) < 20
    ):
        place(e.control, slot)
    e.control.update()

def place(card, slot):
    """放置卡牌到卡槽中"""
    card.top = slot.top
    card.left = slot.left
    page.update()

现在,如果卡牌距离卡槽不够近,我们需要让它弹回初始位置。然而我们并不知道它的初始位置坐标,因为在 on_pan_update 事件中卡牌的 top 和 left 属性已经被修改了。

为了解决这个问题,让我们创建一个 Solitaire 类对象,并在卡牌的 on_pan_start 事件触发时,记录下卡牌的初始位置:

python
class Solitaire:
    def __init__(self):
        self.start_top = 0
        self.start_left = 0

solitaire = Solitaire()

def start_drag(e: ft.DragStartEvent):
    solitaire.start_top = e.control.top
    solitaire.start_left = e.control.left
    e.control.update()

现在让我们在 on_pan_end 事件中加入弹回卡牌的逻辑:

python
def bounce_back(game, card):
    """将卡牌归位到其初始位置"""
    card.top = game.start_top
    card.left = game.start_left
    page.update()

def drop(e: ft.DragEndEvent):
    if (
        abs(e.control.top - slot.top) < 20
        and abs(e.control.left - slot.left) < 20
    ):
        place(e.control, slot)
    else:
        bounce_back(solitaire, e.control)

    e.control.update()

此步骤的完整代码可以在这里找到。

步骤 3:添加第二张卡牌 ​

我们最终需要 52 张牌来玩游戏。为了做概念验证,让我们先添加第二张卡牌:

python
    card2 = ft.GestureDetector(
        mouse_cursor=ft.MouseCursor.MOVE,
        drag_interval=5,
        on_pan_start=start_drag,
        on_pan_update=drag,
        on_pan_end=drop,
        left=100,
        top=0,
        content=ft.Container(bgcolor=ft.Colors.YELLOW, width=70, height=100),
    )

    controls = [slot, card1, card2]
    page.add(ft.Stack(controls=controls, width=1000, height=500))

现在,如果你运行包含两张牌的应用,你会发现移动卡牌时,黄色卡牌(card2)可以正常移动,但绿色卡牌(card1)会被遮挡在黄色卡牌的下方。

drag_and_drop4.gif

这是因为在 Stack 的控件列表中,card2 是在 card1 之后添加的。为了解决这个问题,我们需要在 on_pan_start 事件触发时,把当前拖动的卡牌移动到控件列表的最顶层:

python
def move_on_top(card, controls):
    """将当前拖拽的卡牌移至 Stack 顶层"""
    controls.remove(card)
    controls.append(card)
    page.update()

def start_drag(e: ft.DragStartEvent):
    move_on_top(e.control, controls)
    solitaire.start_top = e.control.top
    solitaire.start_left = e.control.left

现在,两张卡牌都可以顺利拖动而不会发生遮挡冲突了:

drag_and_drop5.gif

此步骤的完整代码可以在这里找到。

步骤 4:添加更多卡槽 ​

作为概念验证应用的最后一步,让我们再添加两个卡槽:

python
slot0 = ft.Container(
    width=70, height=100, left=0, top=0, border=ft.Border.all(1)
)

slot1 = ft.Container(
    width=70, height=100, left=200, top=0, border=ft.Border.all(1)
)

slot2 = ft.Container(
    width=70, height=100, left=300, top=0, border=ft.Border.all(1)
)

slots = [slot0, slot1, slot2]

在创建卡牌时,我们不再单独指定它们的 top 和 left 位置,而是统一放入 slot0 中:

python
# 发牌
place(card1, slot0)
place(card2, slot0)

在 on_pan_end 事件中检查卡牌是否靠近卡槽时,我们现在需要遍历所有的卡槽列表来决定卡牌该放置在哪里:

python
def drop(e: ft.DragEndEvent):
    for slot in slots:
        if (
            abs(e.control.top - slot.top) < 20
        and abs(e.control.left - slot.left) < 20
        ):
            place(e.control, slot)
            e.control.update()
            return

    bounce_back(solitaire, e.control)
    e.control.update()

现在,两张卡牌可以自由地在这三个卡槽之间被拖拽了:

drag_and_drop6.gif

此步骤的完整代码可以在这里找到。

恭喜你完成了纸牌游戏的概念验证!现在你已经学会了如何在 Stack 中使用 GestureDetector 来移动卡牌并将它们精准地放置在指定的 Container 卡槽上。这是构建游戏非常关键的一步。

展开的卡牌堆 ​

在前面的概念验证中,我们实现了将一张卡牌拖拽到靠近的卡槽或者弹回的操作。如果该卡槽上已经有一张牌,新放入的牌会覆盖在它上方,并将旧牌完全遮挡住。

在实际的接龙游戏中,如果牌槽上已经有牌,我们需要将拖动的卡牌向下错开一点位置放置,以便能够看到前面的牌;如果有两张牌,就放得更靠下一些。这种堆叠方式被称为“展开牌堆(Fanned Piles)”。

同时,我们还需要支持从展开的牌堆中间拿起某张卡牌,并拖动它以及它下方的所有卡牌一起移动:

fanned_piles1.gif

为此,卡牌所拖出和移入的卡槽内纸牌的数据(有哪些牌等)信息会非常有帮助。让我们重构我们的程序,以便实现这一展开堆叠效果。

Slot、Card 和 Solitaire 类 ​

每个卡槽都应该有一个 pile 属性,用来保存放置在其上的卡牌列表。现在卡槽是一个 Container 控件,我们无法直接为其添加新的属性。让我们创建一个继承自 Container 的新 Slot 类,并向其添加 pile 属性:

python
SLOT_WIDTH = 70
SLOT_HEIGHT = 100

import flet as ft

class Slot(ft.Container):
    def __init__(self, top, left):
        super().__init__()
        self.pile=[]
        self.width=SLOT_WIDTH
        self.height=SLOT_HEIGHT
        self.left=left
        self.top=top
        self.border=ft.Border.all(1)

同样地,让我们创建一个新的 Card 类,它带有一个 slot 属性来记录当前卡牌所在的卡槽。该类会继承自 GestureDetector,我们将所有卡牌相关的逻辑移入其中:

python
CARD_WIDTH = 70
CARD_HEIGHT = 100
DROP_PROXIMITY = 20

import flet as ft

class Card(ft.GestureDetector):
    def __init__(self, solitaire, color):
        super().__init__()
        self.slot = None
        self.mouse_cursor=ft.MouseCursor.MOVE
        self.drag_interval=5
        self.on_pan_start=self.start_drag
        self.on_pan_update=self.drag
        self.on_pan_end=self.drop
        self.left=None
        self.top=None
        self.solitaire = solitaire
        self.color = color
        self.content=ft.Container(bgcolor=self.color, width=CARD_WIDTH, height=CARD_HEIGHT)

    def move_on_top(self):
        """将拖动的卡牌移至 Stack 顶层"""
        self.solitaire.controls.remove(self)
        self.solitaire.controls.append(self)
        self.solitaire.update()

    def bounce_back(self):
        """将卡牌返回其原始位置"""
        self.top = self.slot.top
        self.left = self.slot.left
        self.update()

    def place(self, slot):
        """放置卡牌到卡槽"""
        self.top = slot.top
        self.left = slot.left
        self.slot = slot

    def start_drag(self, e: ft.DragStartEvent):
        self.move_on_top()
        self.update()

    def drag(self, e: ft.DragUpdateEvent):
        self.top = max(0, self.top + e.local_delta.y)
        self.left = max(0, self.left + e.local_delta.x)
        self.update()

    def drop(self, e: ft.DragEndEvent):
        for slot in self.solitaire.slots:
            if (
                abs(self.top - slot.top) < DROP_PROXIMITY
                and abs(self.left - slot.left) < DROP_PROXIMITY
            ):
                self.place(slot)
                self.update()
                return

        self.bounce_back()
        self.update()

提示

由于现在每张卡牌都拥有了 slot 属性,我们不再需要在 Solitaire 类中记录拖拽卡牌的 start_left 和 start_top 初始坐标,因为我们可以随时通过它绑定的卡槽坐标把牌弹回原位。

让我们更新 Solitaire 类,使其继承自 Stack,并将卡牌和卡槽的创建迁移到此处:

python
SOLITAIRE_WIDTH = 1000
SOLITAIRE_HEIGHT = 500

import flet as ft
from slot import Slot
from card import Card

class Solitaire(ft.Stack):
    def __init__(self):
        super().__init__()
        self.controls = []
        self.slots = []
        self.cards = []
        self.width = SOLITAIRE_WIDTH
        self.height = SOLITAIRE_HEIGHT

    def did_mount(self):
        self.create_card_deck()
        self.create_slots()
        self.deal_cards()

    def create_card_deck(self):
        card1 = Card(self, color="GREEN")
        card2 = Card(self, color="YELLOW")
        self.cards = [card1, card2]

    def create_slots(self):
        self.slots.append(Slot(top=0, left=0))
        self.slots.append(Slot(top=0, left=200))
        self.slots.append(Slot(top=0, left=300))
        self.controls.extend(self.slots)
        self.update()

    def deal_cards(self):
        self.controls.extend(self.cards)
        for card in self.cards:
            card.place(self.slots[0])
        self.update()

注意

如果你尝试在 Solitaire 类的 __init__() 中调用 create_slots()、create_card_deck() 和 deal_cards(),程序会抛出 “Control must be added to the page first”(控件必须先添加到页面)错误。为解决此问题,我们在 did_mount() 方法内部创建卡牌和卡槽,这会在 Stack 被成功添加到页面后立即执行。

我们现在的 main.py 变得非常清爽:

python
import flet as ft
from solitaire import Solitaire

def main(page: ft.Page):

    solitaire = Solitaire()

    page.add(solitaire)

ft.run(main)

此步骤的完整源码可以在这里找到。虽然表现上同 POC 一样,但是面向对象的代码重构让我们可以实现接下来更复杂的功能。

错开排列卡牌 ​

当卡牌在 card.place() 方法中放入某个卡槽时,我们需要做这三件事:

  1. 如果卡牌之前已放置在某卡槽中,将卡牌从原卡槽的 pile 列表中移除;
  2. 将卡牌的 slot 指向新卡槽;
  3. 将卡牌添加到新卡槽的 pile 列表中。
python
def place(self, slot):
    self.top = slot.top
    self.left = slot.left

    # 如果有原始卡槽,从原始卡槽的列表中移出当前卡牌
    if self.slot is not None:
        self.slot.pile.remove(self)

    # 改变卡牌当前绑定的卡槽
    self.slot = slot

    # 将其添加入新卡槽的列表
    slot.pile.append(self)

在更新卡牌的坐标时,卡牌的 left 坐标应与卡槽对齐,但它的 top 坐标取决于新卡槽堆叠的纸牌总数和偏移值 CARD_OFFSET:

python
CARD_OFFSET = 20

...

def place(self, slot):
    self.top = slot.top + len(slot.pile) * CARD_OFFSET
    self.left = slot.left
    ...

这样,新放置的牌便会以偏移量的排布方式叠放,形成如下所示的展开堆叠效果:

fanned_piles1

拖拽一整叠卡牌 ​

如果你现在尝试直接从展开的牌堆底层拖起某张牌,它看起来会是这样(下方的牌没有跟随移动):

drag_pile

为了修复这个问题,我们需要把 card.py 中用于处理单张拖拽的所有方法重构为支持处理整叠卡牌拖拽。

让我们编写一个 get_draggable_pile() 方法,用来获取被选中卡牌以及在其下方叠放的所有卡牌列表:

python
    def get_draggable_pile(self):
        """返回将被共同拖动的一叠卡牌,以当前点击的卡牌开始"""
        if self.slot is not None:
            self.draggable_pile = self.slot.pile[self.slot.pile.index(self) :]
        else:  # 发牌时 slot 仍为 None,只需包含当前卡牌本身
            self.draggable_pile = [self]

由于 self.draggable_pile 是一个新的属性,我们需要在 __init__() 中进行初始化:

python
class Card(ft.GestureDetector):
    def __init__(self, solitaire, color):
        super().__init__()
        ...
        self.draggable_pile = [self]

然后,更新 start_drag() 方法,使其在每次拖动开始时抓取需要协同移动的一叠纸牌:

python
    def start_drag(self, e: ft.DragStartEvent):
        self.get_draggable_pile()
        self.move_on_top()
        self.solitaire.update()

接着,更新 move_on_top() 方法,以便将整叠拖拽的卡牌置于 Stack 顶层:

python
    def move_on_top(self):
        """将拖拽的这叠卡牌全部置于 Stack 顶层"""
        for card in self.draggable_pile:
            self.solitaire.controls.remove(card)
            self.solitaire.controls.append(card)
        self.solitaire.update()

另外,我们还需要更新 drag() 方法,使这叠共同拖拽的所有卡牌随你的鼠标一并联动移动,并且各牌之间依然维持原有的偏移距离:

python
    def drag(self, e: ft.DragUpdateEvent):
        for card in self.draggable_pile:
            card.top = (
                max(0, self.top + e.local_delta.y)
                + self.draggable_pile.index(card) * CARD_OFFSET
            )
            card.left = max(0, self.left + e.local_delta.x)
            self.solitaire.update()

同样地,我们需要修改 place() 方法,在松开卡牌时将整叠牌转移到目标卡槽:

python
    def place(self, slot):
        """将拖动的这叠卡牌放置于新卡槽"""
        for card in self.draggable_pile:
            card.top = slot.top + len(slot.pile) * CARD_OFFSET
            card.left = slot.left

            # 移出原卡槽
            if card.slot is not None:
                card.slot.pile.remove(card)

            # 更新绑定槽
            card.slot = slot

            # 加入新卡槽
            slot.pile.append(card)

        self.solitaire.update()

最后,如果没有在卡槽的可接收范围内松开鼠标,我们需要把这叠拖出来的牌全部归位弹回原来的地方:

python
    def bounce_back(self):
        """将拖动的一整叠卡牌弹回原位"""
        for card in self.draggable_pile:
            card.top = card.slot.top + card.slot.pile.index(card) * CARD_OFFSET
            card.left = card.slot.left
        self.solitaire.update()

此步骤的完整源码可以在这里找到。到这里我们已经顺利支持了折叠牌堆的拖放操作,可以进行游戏整体规则的引入了!

纸牌游戏初始化与布局 ​

让我们来看看维基百科关于 Klondike(接龙纸牌)的介绍:

经典接龙使用一副标准的 52 张牌进行。

洗牌后,将从左到右分发 7 列牌。从左至右,每列放置的牌数依此递增。最左侧第一列放置 1 张正面朝上的牌,第二列放 2 张,第三列放 3 张,依此类推,第七列放 7 张。每列最上方的牌保持正面朝上,其余背面朝下。左上角的剩余牌组成发牌堆(Stock),背面朝下。

右上角的四个基座(Foundations,图中的浅色框)按花色从 A 到 K 依次叠放,而下方 7 列列牌堆(Tableau)可以按红黑花色交替的降序方式放置卡牌。

维基克朗代克纸牌图示

我们现在开始进行这一大步的构建。

创建 52 张标准卡牌 ​

第一步是在 Solitaire 类中生成一副完整的扑克牌。每张牌都有花色 suit 属性(红桃 hearts、方块 diamonds、梅花 clubs、黑桃 spades)和点数 rank 属性(A 到 K)。对于花色,其颜色(红色/黑色)很重要,因为下方列牌堆需要交替颜色摆放。对于点数,其大小很重要,因为右上角的基座要求以升序叠放。

在 solitaire.py 中,定义 Suite 和 Rank 类:

python
class Suite:
    def __init__(self, suite_name, suite_color):
        self.name = suite_name
        self.color = suite_color

class Rank:
    def __init__(self, card_name, card_value):
        self.name = card_name
        self.value = card_value

现在,在 Card 类中,不再接受颜色字符串,而是传入花色 suite 和点数 rank。此外,我们给卡牌添加 face_up(正面朝上)状态属性,在卡牌背面朝上时,将卡牌的 content 显示为卡牌背面的图像:

python
class Card(ft.GestureDetector):
    def __init__(self, solitaire, suite, rank):
        super().__init__()
        self.mouse_cursor=ft.MouseCursor.MOVE
        self.drag_interval=5
        self.on_pan_start=self.start_drag
        self.on_pan_update=self.drag
        self.on_pan_end=self.drop
        self.suite=suite
        self.rank=rank
        self.face_up=False
        self.top=None
        self.left=None
        self.solitaire = solitaire
        self.slot = None
        self.content=ft.Container(
            width=CARD_WIDTH,
            height=CARD_HEIGHT,
            border_radius = ft.BorderRadius.all(6),
            content=ft.Image(src="images/card_back.png"))

所有的卡牌正面图片和背景图片都存放在与 main.py 目录平级的 assets/images 文件夹中。你可以参考相关文档来指定 assets 文件夹。

最后,在 solitaire.create_card_deck() 中生成 52 张扑克牌:

python
def create_card_deck(self):
    suites = [
        Suite("hearts", "RED"),
        Suite("diamonds", "RED"),
        Suite("clubs", "BLACK"),
        Suite("spades", "BLACK"),
    ]
    ranks = [
        Rank("Ace", 1),
        Rank("2", 2),
        Rank("3", 3),
        Rank("4", 4),
        Rank("5", 5),
        Rank("6", 6),
        Rank("7", 7),
        Rank("8", 8),
        Rank("9", 9),
        Rank("10", 10),
        Rank("Jack", 11),
        Rank("Queen", 12),
        Rank("King", 13),
    ]

    self.cards = []

    for suite in suites:
        for rank in ranks:
            self.cards.append(Card(solitaire=self, suite=suite, rank=rank))
划定游戏区域与卡槽 ​

KlondikeSolitaire 游戏布局应如下所示:

布局示意图

现在,在 Slot 类中,我们在初始化时传入 solitaire、border、border_radius 等参数:

python
class Slot(ft.Container):
    def __init__(self, solitaire, top, left, border):
        super().__init__()
        ...
        self.solitaire = solitaire
        self.border = border
        self.border_radius = ft.BorderRadius.all(6)

在 solitaire.create_slots() 中定义好所有卡槽:

python
def create_slots(self):

    self.stock = Slot(self, top=0, left=0, border=ft.Border.all(1))
    self.waste = Slot(self, top=0, left=100, border=None)

    self.foundations = []
    x = 300
    for i in range(4):
        self.foundations.append(Slot(self, top=0, left=x, border=ft.Border.all(1, "outline")))
        x += 100

    self.tableau = []
    x = 0
    for i in range(7):
        self.tableau.append(Slot(self, top=150, left=x, border=None))
        x += 100

    self.controls.append(self.stock)
    self.controls.append(self.waste)
    self.controls.extend(self.foundations)
    self.controls.extend(self.tableau)
    self.update()

提示

注意:部分卡槽需要显示实线轮廓,而部分卡槽不需要(如列牌区背景不需要方框轮廓),所以我们把 border 状态也加入了 Slot 初始化。

分发纸牌 ​

让我们首先把卡牌列表打乱(洗牌),然后添加至控件树:

python
import random

...

def deal_cards(self):
    random.shuffle(self.cards)
    self.controls.extend(self.cards)
    self.update()

接下来,我们把卡牌从左到右分发到 7 个列牌堆中,使得第 N 列拥有 N 张卡牌,其余剩下的牌放入发牌区(stock):

python
def deal_cards(self):
    random.shuffle(self.cards)
    self.controls.extend(self.cards)

    # 发到 7 个列牌堆
    first_slot = 0
    remaining_cards = self.cards

    while first_slot < len(self.tableau):
        for slot in self.tableau[first_slot:]:
            top_card = remaining_cards[0]
            top_card.place(slot)
            remaining_cards.remove(top_card)
        first_slot += 1

    # 剩余卡牌放入 Stock 堆
    for card in remaining_cards:
        card.place(self.stock)

    self.update()

运行程序后:

运行图

我们发现发牌堆中的牌和下方的一样,都错开排列成了展开牌堆。而发牌堆应该是整齐地收拢在一处的。为了解决这个问题,在 card.place() 中我们加入是否在 tableau(列牌区)的条件约束:

python
def place(self, slot):
    """将拖拽卡牌堆放置在新卡槽"""
    for card in self.draggable_pile:
        if slot in self.solitaire.tableau:
            card.top = slot.top + len(slot.pile) * CARD_OFFSET
        else:
            card.top = slot.top
        card.left = slot.left
    ...

这样卡牌在发牌堆就能够整齐堆放了:

修复图

此时如果你去拖动卡牌,会发现因为 card.drop() 中我们仍在使用之前的 3 槽列表遍历,拖放操作会失效。我们需要在 card.drop() 中分别对 Foundations(基座)和 Tableau(列牌区)进行单独的落点计算:

python
def drop(self, e: ft.DragEndEvent):
    for slot in self.solitaire.tableau:
        if (
            abs(self.top - (slot.top + len(slot.pile) * CARD_OFFSET)) < DROP_PROXIMITY
        and abs(self.left - slot.left) < DROP_PROXIMITY
        ):
            self.place(slot)
            self.solitaire.update()
            return

    for slot in self.solitaire.foundations:
        if (
            abs(self.top - slot.top) < DROP_PROXIMITY
        and abs(self.left - slot.left) < DROP_PROXIMITY
        ):
            self.place(slot)
            self.solitaire.update()
            return

    self.bounce_back()
翻开列牌堆最上方的牌 ​

现在我们已经完成了游戏的初始排布。最后一件事就是要把列牌堆最上方的牌翻开呈正面朝上。

在 Slot 类中,创建一个 get_top_card() 方法以获取最上方的一张牌:

python
def get_top_card(self):
    if len(self.pile) > 0:
        return self.pile[-1]

在 Card 类中,创建一个 turn_face_up() 方法来翻开卡牌:

python
def turn_face_up(self):
    self.face_up = True
    self.content.content.src=f"images/{self.rank.name}_{self.suite.name}.svg"
    self.solitaire.update()

最后,在 solitaire.deal_cards() 发牌逻辑的末尾,把 7 个列牌堆最上面的纸牌正面展示出来:

python
def deal_cards(self):
    ...
    self.update()

    for slot in self.tableau:
        slot.get_top_card().turn_face_up()

    self.update()

完成后,界面表现如下:

效果图

此阶段的完整源码在这里。

纸牌游戏规则限制 ​

现在,如果你运行当前的接龙游戏,你会发现你可以在界面上进行一些毫无规则可言的操作(例如把任意红桃叠放在黑桃上或越级放置):

违规效果图

现在我们需要开始对游戏规则加以约束。

通用拖拽条件 ​

在当前的逻辑中,我们可以随意拿起任何卡牌进行拖拽。但在真实的接龙游戏中,只有**正面朝上(face-up)**的卡牌允许被拿取和移动。我们在 start_drag、drag 和 drop 方法中加入 face_up 判定:

python
def start_drag(self, e: ft.DragStartEvent):
    if self.face_up:
        self.move_on_top()
        self.solitaire.update()

def drag(self, e: ft.DragUpdateEvent):
    if self.face_up:
        for card in self.draggable_pile:
            card.top = max(0, self.top + e.local_delta.y) + self.draggable_pile.index(card) * CARD_OFFSET
            card.left = max(0, self.left + e.local_delta.x)
            card.solitaire.update()

def drop(self, e: ft.DragEndEvent):
    if self.face_up:
        for slot in self.solitaire.tableau:
            if (
                abs(self.top - (slot.top + len(slot.pile) * CARD_OFFSET)) < DROP_PROXIMITY
                and abs(self.left - slot.left) < DROP_PROXIMITY
            ):
                self.place(slot)
                return

        for slot in self.solitaire.foundations:
            if (
                abs(self.top - slot.top) < DROP_PROXIMITY
                and abs(self.left - slot.left) < DROP_PROXIMITY
            ):
                self.place(slot)
                return

    self.bounce_back()

接下来,我们注册并编写卡牌的 on_tap 单击回调方法。如果在列牌区(Tableau)中有一张背面朝上的卡牌,且它是该列最上面的卡牌,当被点击时,它将被翻转呈正面朝上:

python
class Card(ft.GestureDetector):
    def __init__(self, solitaire, suite, rank):
        ...
        self.on_tap = self.click
        ...

    def click(self, e):
        if self.slot in self.solitaire.tableau:
            if not self.face_up and self == self.slot.get_top_card():
                self.turn_face_up()
                self.solitaire.update()

运行查看效果:

翻牌示意图

基座(Foundations)叠放规则 ​

目前我们允许直接拖拽一叠展开的牌到 Foundations 基座中,这显然违反了规则。基座只允许单张放入。我们应该限制被拖拽的 draggable_pile 长度为 1:

python
def drop(self, e: ft.DragEndEvent):
    if self.face_up:
        for slot in self.solitaire.tableau:
            if (
                abs(self.top - (slot.top + len(slot.pile) * CARD_OFFSET)) < DROP_PROXIMITY
                and abs(self.left - slot.left) < DROP_PROXIMITY
            ):
                self.place(slot)
                return

        if len(self.draggable_pile) == 1:
            for slot in self.solitaire.foundations:
                if (
                    abs(self.top - slot.top) < DROP_PROXIMITY
                and abs(self.left - slot.left) < DROP_PROXIMITY
                ):
                    self.place(slot)
                    return

    self.bounce_back()

同时,基座对放入的卡牌有着点数和花色要求:每个基座槽最初只能接收 Ace,此后后续放入的纸牌花色必须相同,且点数严格递增 1 点(例如梅花 A 上方只可放梅花 2,其上再放梅花 3)。

在 Solitaire 中增加基座摆放的检查规则:

python
def check_foundations_rules(self, card, slot):
    top_card = slot.get_top_card()
    if top_card is not None:
        return (
            card.suite.name == top_card.suite.name
            and card.rank.value - top_card.rank.value == 1
        )
    else:
        return card.rank.name == "Ace"

并在 drop() 方法的基座验证逻辑中执行它:

python
        if len(self.draggable_pile) == 1:
            for slot in self.solitaire.foundations:
                if (
                    abs(self.top - slot.top) < DROP_PROXIMITY
                    and abs(self.left - slot.left) < DROP_PROXIMITY
                ) and self.solitaire.check_foundations_rules(self, slot):
                    self.place(slot)
                    return

最后,让我们为卡牌支持双击事件 on_double_tap。双击时自动搜索基座槽,若符合某基座的叠放法则,则直接一步飞至基座:

python
class Card(ft.GestureDetector):
    def __init__(self, solitaire, suite, rank):
        ...
        self.on_double_tap = self.doubleclick
        ...

    def doubleclick(self, e):
        self.get_draggable_pile()
        if self.face_up and len(self.draggable_pile) == 1:
            self.move_on_top()
            for slot in self.solitaire.foundations:
                if self.solitaire.check_foundations_rules(self, slot):
                    self.place(slot)
                    return
列牌堆(Tableau)叠放规则 ​

接着,我们限制列牌堆内的叠牌限制:必须是由大到小降序排列(前一张点数减去当前纸牌点数必须为 1),且相邻两张卡牌的花色必须为红黑相间(即红色花色与黑色花色交替摆放)。如果目标卡槽完全为空,那么该空卡槽只允许放入点数是 King 的牌。

在 Solitaire 类中编写规则验证函数:

python
def check_tableau_rules(self, card, slot):
    top_card = slot.get_top_card()
    if top_card is not None:
        return (
            card.suite.color != top_card.suite.color
            and top_card.rank.value - card.rank.value == 1
            and top_card.face_up
        )
    else:
        return card.rank.name == "King"

在 drop() 时应用该规则:

python
def drop(self, e: ft.DragEndEvent):
    if self.face_up:
        for slot in self.solitaire.tableau:
            if (
                abs(self.top - (slot.top + len(slot.pile) * CARD_OFFSET)) < DROP_PROXIMITY
                and abs(self.left - slot.left) < DROP_PROXIMITY
            ) and self.solitaire.check_tableau_rules(self, slot):
                self.place(slot)
                return
发牌堆与废牌堆交互 ​

为了能够发牌,我们需要点击发牌堆(Stock)上的牌,然后让卡牌飞到废牌堆(Waste)中,并自动翻转正面朝上展示。

更新卡牌点击 click() 事件:

python
def click(self, e):
    if self.slot in self.solitaire.tableau:
        if not self.face_up and self == self.slot.get_top_card():
            self.turn_face_up()
    elif self.slot == self.solitaire.stock:
        self.move_on_top()
        self.place(self.solitaire.waste)
        self.turn_face_up()

在 Card 中,定义用于重新翻回背面的 turn_face_down() 方法:

python
def turn_face_down(self):
    """将卡牌隐藏(显示背面)"""
    self.face_up = False
    self.content.content.src = "/images/card_back.png"
    self.solitaire.update()

如果在发牌堆用光后我们需要重新循环把废牌堆里的牌收拢放回发牌堆,我们应该为发牌槽(Stock Slot)的 on_click 绑定重置事件:

python
class Slot(ft.Container):
   def __init__(self, solitaire, top, left, border):
       super().__init__()
       self.pile=[]
       self.width=SLOT_WIDTH
       self.height=SLOT_HEIGHT
       self.left=left
       self.top=top
       self.on_click=self.click
       self.solitaire=solitaire
       self.border=border
       self.border_radius = ft.BorderRadius.all(6)

   def click(self, e):
       if self == self.solitaire.stock:
           self.solitaire.restart_stock()

restart_stock() 重置方法会将废牌堆里堆放的所有卡牌按原来的顺序收回,并翻回背面重新收拢在发牌堆内:

python
def restart_stock(self):
    while len(self.waste.pile) > 0:
        card = self.waste.get_top_card()
        card.turn_face_down()
        card.move_on_top()
        card.place(self.stock)

为确保从发牌堆或废牌堆移动卡牌时它们只是单张处理,在 Card.get_draggable_pile() 中加一层排除 Stock/Waste 槽的逻辑,强制返回只包含它本身的列表:

python
    def get_draggable_pile(self):
        """返回被拖动的一叠卡牌"""
        if (
            self.slot is not None
            and self.slot != self.solitaire.stock
            and self.slot != self.solitaire.waste
        ):
            self.draggable_pile = self.slot.pile[self.slot.pile.index(self) :]
        else:  # 发牌区和废牌区卡牌只需一张
            self.draggable_pile = [self]

至此,我们的游戏逻辑规则已经全部覆盖完成了!此阶段完整代码可以参考这里。

赢得游戏 ​

经典接龙纸牌的过关几率大概在 30 局中只会出现 1 次。

因此,当好不容易通关时,我们应该为用户准备一些极其绚丽的动画!

首先,在 Solitaire 类中加入胜利判定逻辑:如果 4 个 Foundation 槽的 pile 叠牌总数达到了 52 张,那么代表通关了:

python
def check_win(self):
    cards_num = 0
    for slot in self.foundations:
        cards_num += len(slot.pile)
    if cards_num == 52:
        return True
    return False

我们在每张卡牌成功放置在 Foundation 基座槽中时检测是否满足这一过关判定:

python
def place(self, slot):
    """放置卡牌"""
    for card in self.draggable_pile:
        if slot in self.solitaire.tableau:
            card.top = slot.top + len(slot.pile) * CARD_OFFSET
        else:
            card.top = slot.top
        card.left = slot.left

        if card.slot is not None:
            card.slot.pile.remove(card)

        card.slot = slot
        slot.pile.append(card)

    if self.solitaire.check_win():
        self.solitaire.winning_sequence()

    self.solitaire.update()

通关后,会触发自动将卡牌向四周散开飞行的胜利序列动画,同时弹出一个恭喜对话框:

python
def winning_sequence(self):
    for slot in self.foundations:
        for card in slot.pile:
            card.animate_position=1000
            card.move_on_top()
            card.top = random.randint(0, SOLITAIRE_HEIGHT)
            card.left = random.randint(0, SOLITAIRE_WIDTH)
            self.update()
    self.controls.append(ft.AlertDialog(title=ft.Text("恭喜通关!你赢了!"), open=True))

胜利效果

你可以在这里找到纸牌游戏的最终完整源码。

部署应用 ​

恭喜!你已经使用 Flet 编写了你的 Python 纸牌游戏,它看起来真的很棒!

请按照相关说明将你的 Flet 游戏作为网页应用部署到 Fly.io 或 Replit,与大家分享!

总结 ​

在本教程中,你学习了如何:

  • 创建一个简单的 Flet 应用;
  • 使用 GestureDetector 实现拖拽卡牌交互;
  • 创建继承自 Flet 原生控件的自定义类;
  • 在 Stack 布局中利用绝对定位进行 UI 排版;
  • 使用隐式位置动画展示动画序列;
  • 将你的 Flet 应用部署到网页端;

要进一步阅读,你可以探索 controls 和 examples。

我们非常期待收到你的反馈!欢迎通过电子邮件联系我们,或者加入 Discord 社区参与讨论。


关注我,学习大模型、大数据、Python等热门技术。