python_2048_game

第 5 章 用户界面开发

“界面是用户的游戏世界” —— 用 Textual 打造精美终端 UI


📋 本章内容


🖥️ Textual 框架简介

什么是 Textual?

Textual 是一个用 Python 构建终端用户界面(TUI)的框架。

类比理解:

Web 开发 Textual TUI 说明
HTML Widgets 界面组件
CSS CSS 样式定义
JavaScript Python 交互逻辑
DOM Widget Tree 组件树
React/Vue Textual App 应用框架

为什么选择 Textual?

传统终端库(curses):
❌ API 老旧,难以使用
❌ 跨平台兼容性差
❌ 不支持现代 UI 特性

Textual:
✅ 现代化设计
✅ 支持事件驱动
✅ 丰富的组件库
✅ 自动处理终端差异
✅ 类似 Web 开发体验

安装与验证

# 已通过 uv 安装
uv add textual

# 验证安装
uv run python -c "import textual; print(textual.__version__)"

🧩 组件系统

Widget 层次结构

App (应用)
└── Screen (屏幕)
    └── Widget (组件基类)
        ├── Static (静态内容)
        │   ├── Label (文本标签)
        │   └── Button (按钮)
        ├── Input (输入框)
        ├── ListView (列表视图)
        └── ... (更多组件)

组件生命周期

from textual.app import ComposeResult
from textual.widgets import Static, Label

class MyWidget(Static):
    """自定义组件。"""
    
    def __init__(self):
        """1. 初始化(创建实例)"""
        super().__init__()
        self.data = None
    
    def compose(self) -> ComposeResult:
        """2. 组合子组件(构建 UI)"""
        yield Label("Hello")
        yield Button("Click me")
    
    def on_mount(self) -> None:
        """3. 挂载后(组件已添加到屏幕)"""
        # 可以在这里进行异步操作
        self.fetch_data()
    
    def on_unmount(self) -> None:
        """4. 卸载时(组件即将移除)"""
        # 清理资源
        pass

组件树示例

# 2048 游戏的组件树
Game2048App
├── Header
├── GameScreen (Vertical)
   ├── Label (标题 "🎮 2048")
   ├── Label (操作提示)
   ├── ScoreWidget (Horizontal)
      ├── Label (Score)
      ├── Label (Moves)
      └── Label (Max)
   └── GridWidget
       ├── Horizontal (row 0)
          ├── TileWidget (tile-0-0)
          ├── TileWidget (tile-0-1)
          ├── TileWidget (tile-0-2)
          └── TileWidget (tile-0-3)
       ├── Horizontal (row 1)
          └── ...
       └── ...
└── Footer

🎨 自定义组件开发

TileWidget - 单个方块

class TileWidget(Static):
    """单个方块组件。"""
    
    # CSS 样式(内联定义)
    DEFAULT_CSS = """
    TileWidget {
        width: 1fr;
        height: 100%;
        content-align: center middle;
        background: $surface;
        border: solid $primary-background;
        margin: 0 1;
        text-style: bold;
    }
    
    TileWidget.tile-empty {
        background: $surface-darken-1;
        color: $text-muted;
    }
    """
    
    def __init__(self, value: int, row: int, col: int):
        """初始化方块。"""
        # 生成唯一 ID,方便后续查询
        super().__init__(id=f"tile-{row}-{col}")
        self.value = value
        self.row = row
        self.col = col
        self._update_classes()
    
    def _update_classes(self) -> None:
        """根据值更新 CSS 类。"""
        self.remove_class("tile-empty")
        if self.value == 0:
            self.add_class("tile-empty")
    
    def update_value(self, value: int) -> None:
        """更新方块的值。"""
        self.value = value
        self._update_classes()
        self.refresh()  # 触发重新渲染
    
    def render(self) -> str:
        """渲染方块内容。"""
        return str(self.value) if self.value != 0 else ""

关键知识点:

概念 说明 示例
DEFAULT_CSS 组件默认样式 内联 CSS 字符串
id 组件唯一标识 id=f"tile-{row}-{col}"
classes CSS 类名 add_class("tile-empty")
render() 自定义渲染 返回要显示的字符串
refresh() 触发重绘 更新 UI 显示

GridWidget - 4x4 棋盘

class GridWidget(Static):
    """4x4 棋盘组件。"""
    
    DEFAULT_CSS = """
    GridWidget {
        width: 100%;
        height: 18;  /* 固定高度,确保 4 行能显示 */
        background: $surface-darken-2;
        border: solid $primary;
        padding: 1;
    }
    
    .tile-row {
        height: 4;
        width: 100%;
    }
    """
    
    def __init__(self, game: Game):
        super().__init__()
        self.game = game
        self.tile_widgets: list[list[TileWidget]] = []
    
    def compose(self) -> ComposeResult:
        """组合棋盘组件。"""
        self.tile_widgets = []
        
        for row in range(GRID_SIZE):
            row_widgets = []
            for col in range(GRID_SIZE):
                # 创建方块组件
                tile = TileWidget(
                    value=self.game.grid.cells[row][col],
                    row=row,
                    col=col
                )
                row_widgets.append(tile)
            
            self.tile_widgets.append(row_widgets)
            
            # 每行用一个 Horizontal 容器
            yield Horizontal(*row_widgets, classes="tile-row")
    
    def refresh_grid(self) -> None:
        """刷新整个棋盘。"""
        for row in range(GRID_SIZE):
            for col in range(GRID_SIZE):
                value = self.game.grid.cells[row][col]
                tile = self.tile_widgets[row][col]
                tile.update_value(value)
        
        self.refresh()

设计要点:

  1. 保存引用 - self.tile_widgets 保存所有 TileWidget 引用,方便后续更新
  2. 批量创建 - 在 compose() 中一次性创建所有子组件
  3. 按需更新 - refresh_grid() 只更新值,不重新创建组件

ScoreWidget - 分数显示

class ScoreWidget(Static):
    """分数显示组件。"""
    
    DEFAULT_CSS = """
    ScoreWidget {
        width: 100%;
        height: auto;
        background: $primary-background;
        padding: 1 2;
        margin: 1 0;
    }
    
    #score-label {
        text-style: bold;
        color: $text;
    }
    
    #moves-label {
        color: $text-muted;
    }
    
    #max-tile-label {
        color: $warning;
    }
    """
    
    def __init__(self, game: Game):
        super().__init__()
        self.game = game
        # 保存子组件引用
        self.score_label: Label | None = None
        self.moves_label: Label | None = None
        self.max_label: Label | None = None
    
    def compose(self) -> ComposeResult:
        """组合分数显示。"""
        self.score_label = Label(f"Score: {self.game.score}", id="score-label")
        self.moves_label = Label(f"Moves: {self.game.moves}", id="moves-label")
        self.max_label = Label(f"Max: {self.game.grid.get_max_tile()}", id="max-tile-label")
        
        yield Horizontal(self.score_label, self.moves_label, self.max_label)
    
    def update(self) -> None:
        """更新分数显示。"""
        if self.score_label:
            self.score_label.update(f"Score: {self.game.score}")
        if self.moves_label:
            self.moves_label.update(f"Moves: {self.game.moves}")
        if self.max_label:
            self.max_label.update(f"Max: {self.game.grid.get_max_tile()}")

🎨 CSS 样式系统

Textual CSS 基础

/* 选择器 */
WidgetType {          /* 类型选择器 */
    property: value;
}

#id-name {            /* ID 选择器 */
    property: value;
}

.class-name {         /* 类选择器 */
    property: value;
}

Parent > Child {      /* 子选择器 */
    property: value;
}

/* 常用属性 */
Widget {
    /* 尺寸 */
    width: 100%;
    height: auto;
    max-height: 20;
    
    /* 布局 */
    display: flex;
    flex-direction: column;
    
    /* 间距 */
    padding: 1 2;      /* 上下 1,左右 2 */
    margin: 1 0;
    
    /* 对齐 */
    content-align: center middle;  /* 内容居中 */
    text-align: center;            /* 文本居中 */
    
    /* 样式 */
    background: $surface;
    border: solid $primary;
    color: $text;
    text-style: bold;
    
    /* 变量(主题色) */
    /* $surface, $primary, $text, $warning, $error 等 */
}

主题色变量

Textual 提供了一套主题色变量:

$surface           - 背景色
$surface-darken-1  - 深一度的背景
$surface-lighten-1 - 浅一度的背景

$primary           - 主色调
$primary-background - 主色背景
$secondary         - 次色调

$text              - 文本颜色
$text-muted        - 弱化文本

$warning           - 警告色(黄色)
$error             - 错误色(红色)
$success           - 成功色(绿色)

不同值的方块样式

# 为不同值的方块定义不同颜色
TILE_STYLES = {
    2: {"bg": "#eee4da", "fg": "#776e65"},
    4: {"bg": "#ede0c8", "fg": "#776e65"},
    8: {"bg": "#f2b179", "fg": "#f9f6f2"},
    16: {"bg": "#f59563", "fg": "#f9f6f2"},
    32: {"bg": "#f67c5f", "fg": "#f9f6f2"},
    64: {"bg": "#f65e3b", "fg": "#f9f6f2"},
    128: {"bg": "#edcf72", "fg": "#f9f6f2"},
    256: {"bg": "#edcc61", "fg": "#f9f6f2"},
    512: {"bg": "#edc850", "fg": "#f9f6f2"},
    1024: {"bg": "#edc53f", "fg": "#f9f6f2"},
    2048: {"bg": "#edc22e", "fg": "#f9f6f2"},
}

# 在 TileWidget 中应用
def _update_classes(self) -> None:
    # 移除所有值相关的类
    for value in TILE_STYLES.keys():
        self.remove_class(f"tile-{value}")
    
    # 添加当前值的类
    if self.value in TILE_STYLES:
        self.add_class(f"tile-{self.value}")
/* CSS 中定义不同值的样式 */
.tile-2 { background: #eee4da; }
.tile-4 { background: #ede0c8; }
.tile-8 { background: #f2b179; }
/* ... */

⚡ 事件处理

事件类型

Textual 中的常见事件:

事件 触发时机 处理方法
on_mount 组件挂载到屏幕 def on_mount(self)
on_unmount 组件从屏幕移除 def on_unmount(self)
on_key 按键按下 def on_key(self, event)
on_click 鼠标点击 def on_click(self, event)
on_button_pressed 按钮按下 def on_button_pressed(self, event)
on_input_changed 输入框内容变化 def on_input_changed(self, event)

键盘绑定(Bindings)

from textual.binding import Binding

class Game2048App(App):
    """主应用。"""
    
    # 定义键盘绑定
    BINDINGS = [
        # (按键,方法名,描述)
        Binding("up", "move_up", "向上移动"),
        Binding("down", "move_down", "向下移动"),
        Binding("left", "move_left", "向左移动"),
        Binding("right", "move_right", "向右移动"),
        
        # WASD 备用
        Binding("w", "move_up", "W", show=False),
        Binding("a", "move_left", "A", show=False),
        Binding("s", "move_down", "S", show=False),
        Binding("d", "move_right", "D", show=False),
        
        # 游戏控制
        Binding("r", "restart", "重新开始"),
        Binding("q", "quit", "退出"),
        
        # 帮助
        Binding("question_mark", "show_help", "帮助"),
    ]
    
    # action_ 前缀的方法会被绑定调用
    def action_move_up(self):
        self._attempt_move("up")
    
    def action_restart(self):
        self.game.reset()
        self.refresh_display()
        self.notify("Game restarted!", title="🔄")
    
    def action_quit(self):
        self.exit()

事件处理流程

用户按下 ← 键
    ↓
Textual 事件系统捕获
    ↓
匹配 BINDINGS 中的 "left"
    ↓
调用 action_move_left() 方法
    ↓
执行游戏逻辑
    ↓
更新 UI 显示

模态对话框

from textual.screen import ModalScreen
from textual.widgets import Button, Label

class GameOverModal(ModalScreen):
    """游戏结束模态框。"""
    
    BINDINGS = [
        Binding("enter", "restart", "重新开始"),
        Binding("escape", "quit", "关闭"),
    ]
    
    DEFAULT_CSS = """
    GameOverModal {
        align: center middle;  /* 居中显示 */
    }
    
    #game-over-container {
        width: 50;
        height: auto;
        background: $surface;
        border: solid $error;
        padding: 2 4;
    }
    """
    
    def __init__(self, game: Game):
        super().__init__()
        self.game = game
    
    def compose(self) -> ComposeResult:
        state_text = "🎉 You Win!" if self.game.state == GameState.WON else "💀 Game Over"
        
        with Vertical(id="game-over-container"):
            yield Label(state_text, id="game-over-title")
            yield Label(f"Final Score: {self.game.score}", id="game-over-score")
            yield Button("Play Again", id="restart-btn", variant="primary")
    
    def on_button_pressed(self, event: Button.Pressed) -> None:
        if event.button.id == "restart-btn":
            self.dismiss(True)  # 关闭并返回 True
    
    def action_restart(self):
        self.dismiss(True)

使用模态框:

# 在主应用中
def _show_game_over(self) -> None:
    def callback(restart: bool) -> None:
        if restart:
            self.game.reset()
            self.refresh_display()
    
    # push_screen 打开模态框,callback 接收返回值
    self.push_screen(GameOverModal(self.game), callback)

🔄 数据绑定与更新

被动更新模式

# ❌ 错误:UI 直接修改游戏数据
class TileWidget:
    def on_click(self):
        self.value = 2048  # 直接修改,绕过游戏逻辑

# ✅ 正确:UI 只反映游戏状态
class TileWidget:
    def update_value(self, value: int):
        self.value = value
        self.refresh()

# 游戏逻辑修改数据
game.move("left")  # 修改 grid.cells

# UI 被动更新
grid_widget.refresh_grid()  # 从 game.grid 读取新状态

批量更新优化

# ❌ 低效:每次移动都刷新
def move(self, direction):
    self.game.move(direction)
    self.score_widget.update()  # 刷新分数
    self.grid_widget.refresh_grid()  # 刷新棋盘
    # 多次刷新 = 多次重绘

# ✅ 高效:批量更新
def move(self, direction):
    self.game.move(direction)
    self.refresh_display()  # 一次刷新所有

def refresh_display(self):
    with self.batch_update():  # 批量更新上下文
        self.score_widget.update()
        self.grid_widget.refresh_grid()

🛠️ 实践任务

任务 1:添加动画效果

from textual.animation import animate

class TileWidget(Static):
    def slide_to(self, new_row: int, new_col: int):
        """滑动到新位置(带动画)。"""
        # 计算目标位置
        target_x = new_col * TILE_WIDTH
        target_y = new_row * TILE_HEIGHT
        
        # 动画移动
        animate(
            self,
            "offset",
            Offset(target_x, target_y),
            duration=0.1,
            easing="in_out_quad"
        )

任务 2:实现主题切换

class Game2048App(App):
    THEMES = {
        "default": {...},
        "dark": {...},
        "light": {...},
    }
    
    def action_toggle_theme(self):
        """切换主题。"""
        current = self.theme
        next_theme = "dark" if current == "default" else "default"
        self.theme = next_theme
        self.notify(f"Theme: {next_theme}")

任务 3:添加粒子效果

class MergeParticle(Static):
    """合并时的粒子效果。"""
    
    DEFAULT_CSS = """
    MergeParticle {
        position: absolute;
        width: 4;
        height: 2;
        background: $warning;
    }
    """
    
    def on_mount(self):
        # 向上飘动并消失
        self.animate("opacity", 0, duration=0.5)
        self.animate("offset", self.offset + Offset(0, -2), duration=0.5)

任务 4:响应式布局

class GridWidget(Static):
    """响应式棋盘。"""
    
    def on_resize(self, event):
        """窗口大小变化时调整。"""
        if event.size.width < 60:
            # 小屏幕:缩小字体
            self.add_class("small")
        else:
            self.remove_class("small")

❓ 常见问题

Q1: 组件不显示怎么办?

A: 检查清单:

  1. 组件是否 yield 出来了?
  2. 父容器是否有正确尺寸?
  3. CSS 是否隐藏了组件?
  4. 调用 refresh() 了吗?

Q2: 如何调试 UI 问题?

A:

# 启用 Textual 调试模式
# 运行:textual run app.py

# 添加日志
def on_key(self, event):
    self.notify(f"Key pressed: {event.key}")

# 查看组件树
# 按 Ctrl+P 打开组件检查器(调试模式)

Q3: 如何处理终端大小变化?

A: Textual 自动处理大部分情况。如需自定义:

def on_resize(self, event):
    # event.size.width, event.size.height
    self.refresh_layout()

📚 延伸阅读


下一章: 第 6 章 应用入口与事件处理

🐧 好的 UI 让游戏更有趣!