Files
game-cards/docs/superpowers/plans/2026-06-02-hand-wrap-and-play-animation.md

17 KiB
Raw Permalink Blame History

手牌换行 + 出牌动画 + 点评 — 实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在训练模式中实现手牌自动换行FlowContainer、按 rank 排序显示,并加入"飞行 + 缩放"出牌动画 + 桌面上方基础点评浮层。

Architecture: 4 个文件改动,全部位于 src/ui/ 层;业务逻辑层 src/core/* 零改动。FlowContainer 接管手牌区布局;training_room.gd 用 Tween + ghost CardNode 实现飞行CommentaryLabel 通过 modulate.a tween 做 fade。

Tech Stack: Godot 4.xFlowContainer、Control.scale/global_position、Tween、modulate

Spec: docs/superpowers/specs/2026-06-02-hand-wrap-and-play-animation-design.md

注: 本次为 UI 改造,遵循设计文档 §五"UI 测试:手动 + 截图对比"不新增 GUT 用例。每个任务的验证通过 Godot 编辑器运行 training_room.tscn 手动观察。


文件结构

状态 路径 职责
修改 src/ui/components/hand_area.tscn 根节点 HBoxContainer → FlowContainer
修改 src/ui/components/hand_area.gd extends 改 FlowContainerupdate_hand 排序
修改 src/ui/scenes/training_room.tscn ScrollContainer 高度/模式;新增 CommentaryLabel + TableCardRootHandArea 实例 type 改 FlowContainer
修改 src/ui/scenes/training_room.gd 新增常量、@onready 引用、_show_commentary、_find_hand_node重写 _on_cards_played_show_table_cards 父节点改 table_card_root
不变 src/core/*src/ai/*src/game/*src/autoload/*assets/*tests/* 0 改动

Task 1: 手牌区改用 FlowContainer场景层

Files:

  • Modify: src/ui/components/hand_area.tscn

  • Modify: src/ui/scenes/training_room.tscn

  • Step 1: 改 hand_area.tscn 根节点类型

打开 src/ui/components/hand_area.tscn,将整个文件内容替换为:

[gd_scene load_steps=2 format=3 uid="uid://hand_area"]

[ext_resource type="Script" path="res://src/ui/components/hand_area.gd" id="1_script"]

[node name="HandArea" type="FlowContainer"]
script = ExtResource("1_script")
  • Step 2: 改 training_room.tscn 中 HandArea 实例的 type 与尺寸

打开 src/ui/scenes/training_room.tscn,定位到第 47-51 行HandArea 节点),将:

[node name="HandArea" type="HBoxContainer" parent="ScrollContainer"]
layout_mode = 2
size_flags_horizontal = 0
script = ExtResource("2_script")
custom_minimum_size = Vector2(2200, 140)

改为:

[node name="HandArea" type="FlowContainer" parent="ScrollContainer"]
layout_mode = 2
size_flags_horizontal = 3
script = ExtResource("2_script")
custom_minimum_size = Vector2(1240, 200)
  • Step 3: 改 training_room.tscn 中 ScrollContainer 区域

定位到第 39-45 行ScrollContainer 节点),将:

[node name="ScrollContainer" type="ScrollContainer" parent="."]
layout_mode = 0
offset_top = 380.0
offset_right = 1280.0
offset_bottom = 520.0
horizontal_scroll_mode = 2
vertical_scroll_mode = 0

改为:

[node name="ScrollContainer" type="ScrollContainer" parent="."]
layout_mode = 0
offset_top = 380.0
offset_right = 1280.0
offset_bottom = 660.0
horizontal_scroll_mode = 0
vertical_scroll_mode = 2
  • Step 4: 验证场景在 Godot 编辑器中加载无误

打开 Godot 编辑器 → 打开 project.godot → 打开 src/ui/scenes/training_room.tscn。 预期场景正常加载错误列表无报错F5 运行后 27 张手牌自动分两行(会因 extends HBoxContainer 警告未消除而出现一行排开 —— 这是预期的,因为脚本尚未修改;本次任务到此完成,下一任务修复脚本)。

  • Step 4b: 同步调整 Buttons 位置避免与新 ScrollContainer 重叠

新 ScrollContainer 占 380660高 280Buttons HBoxContainer 在 540600会被覆盖。将第 53-58 行:

[node name="Buttons" type="HBoxContainer" parent="."]
layout_mode = 0
offset_top = 540.0
offset_right = 1280.0
offset_bottom = 600.0
alignment = 1

改为:

[node name="Buttons" type="HBoxContainer" parent="."]
layout_mode = 0
offset_top = 670.0
offset_right = 1280.0
offset_bottom = 710.0
alignment = 1

(视口高 720Buttons 落 670710留 10px 边距与 ScrollContainer 间隔。)

  • Step 5: 提交
git add src/ui/components/hand_area.tscn src/ui/scenes/training_room.tscn
git commit -m "feat(ui): migrate hand area to FlowContainer for auto-wrap"

Task 2: 手牌按 rank+suit 排序显示

Files:

  • Modify: src/ui/components/hand_area.gd

  • Step 1: 改 extendsupdate_hand()

打开 src/ui/components/hand_area.gd,将:

class_name HandArea
extends HBoxContainer

改为:

class_name HandArea
extends FlowContainer

然后定位到 update_hand() 方法(第 15-28 行),将整个方法替换为:

func update_hand(hand: Array) -> void:
    for cn in card_nodes:
        cn.queue_free()
    card_nodes.clear()
    selected_cards.clear()

    var sorted_hand: Array[Card] = []
    for c in hand:
        sorted_hand.append(c)
    sorted_hand.sort_custom(func(a: Card, b: Card): return a.compare_to(b) < 0)

    for c in sorted_hand:
        var node := CARD_SCENE.instantiate() as CardNode
        if node == null:
            continue
        add_child(node)
        node.setup(c)
        node.card_clicked.connect(_on_card_clicked)
        node.card_double_clicked.connect(_on_card_double_clicked)
        card_nodes.append(node)
  • Step 2: 验证

F5 运行 training_room.tscn。 预期:

  • 27 张手牌按 rank 升序排列2 → A → 小王 → 大王),同行内 suit 升序

  • 牌数超过 1240 宽度时自动换到第二行

  • 横向无滚动条纵向无滚动条27 张两行能装下 280 高度)

  • 点击、选中、双击交互正常

  • Step 3: 提交

git add src/ui/components/hand_area.gd
git commit -m "feat(ui): sort hand by rank+suit before render"

Task 3: 在 training_room.tscn 新增 CommentaryLabel 与 TableCardRoot 节点

Files:

  • Modify: src/ui/scenes/training_room.tscn

  • Step 1: 在 ScrollContainer 节点之后追加新节点

打开 src/ui/scenes/training_room.tscn,定位到第 70 行文件末尾HintButton 节点之后),在 HintButton 节点块的下方追加:


[node name="CommentaryLabel" type="Label" parent="."]
layout_mode = 0
offset_top = 360.0
offset_right = 1280.0
offset_bottom = 380.0
horizontal_alignment = 1
mouse_filter = 2
modulate = Color(1, 1, 1, 0)

[node name="TableCardRoot" type="Control" parent="."]
layout_mode = 0
offset_top = 85.0
offset_right = 1280.0
offset_bottom = 370.0
mouse_filter = 2

(提示:每行以换行符分隔,新节点需要 1 个空行与上一个节点块隔开。)

  • Step 2: 验证

打开 training_room.tscn 场景。 预期:场景树中 TrainingRoom 下新增 CommentaryLabelTableCardRoot 两个节点F5 运行无报错。

  • Step 3: 提交
git add src/ui/scenes/training_room.tscn
git commit -m "feat(ui): add CommentaryLabel and TableCardRoot nodes"

Task 4: 在 training_room.gd 添加常量、@onready 引用、辅助方法

Files:

  • Modify: src/ui/scenes/training_room.gd

  • Step 1: 顶部加常量

打开 src/ui/scenes/training_room.gd,在第 3 行(const CARD_NODE_SCENE 之后)新增:


const FLIGHT_DURATION := 0.30
const COMMENTARY_FADE_IN := 0.20
const COMMENTARY_HOLD := 0.60
const COMMENTARY_FADE_OUT := 0.30
  • Step 2: 在 @onready 引用区追加两个引用

定位到第 12-14 行(@onready var status_label/guide_label/table_label),在其后追加:

@onready var commentary_label: Label = $CommentaryLabel
@onready var table_card_root: Control = $TableCardRoot
  • Step 3: 在 _clear_table_cards() 之后追加两个辅助方法

定位到第 122-125 行(_clear_table_cards() 方法),在其后追加:


func _show_commentary(player_idx: int, play: HandEvaluator.EvaluatedPlay) -> void:
    if not commentary_label:
        return
    var pname := controller.game_state.player_names[player_idx]
    var type_name := _get_type_name(play.type)
    commentary_label.text = "%s 出了 %s%d张,主阶=%d" \
        % [pname, type_name, play.cards.size(), play.primary_rank]
    commentary_label.modulate.a = 0.0
    var t := create_tween()
    t.tween_property(commentary_label, "modulate:a", 1.0, COMMENTARY_FADE_IN)


func _find_hand_node(card: Card) -> CardNode:
    for cn in hand_area.card_nodes:
        if cn.card_data != null and cn.card_data.card_id == card.card_id:
            return cn
    return null
  • Step 4: 验证

F5 运行。 预期:场景加载无报错;此时 _on_cards_played 尚未重写,所以"出牌"行为与之前一致(桌牌直接出现,无动画)。这是预期的。

  • Step 5: 提交
git add src/ui/scenes/training_room.gd
git commit -m "feat(ui): add commentary helpers and constants"

Task 5: 重写 _on_cards_played() 实现飞行动画 + 点评串行

Files:

  • Modify: src/ui/scenes/training_room.gd

  • Step 1: 定位现有 _on_cards_played

打开 src/ui/scenes/training_room.gd,定位到第 79-89 行(现有 _on_cards_played 方法):

func _on_cards_played(player_idx: int, play: HandEvaluator.EvaluatedPlay) -> void:
    # 显示出的牌
    _show_table_cards(play.cards)
    var player_name := controller.game_state.player_names[player_idx]
    var type_name := _get_type_name(play.type)
    if status_label:
        status_label.text = "%s 出了 %s%d张)" % [player_name, type_name, play.cards.size()]

    # 延迟刷新手牌,让玩家看到出牌效果
    await get_tree().create_timer(0.8).timeout
    _refresh_ui()
  • Step 2: 替换为新实现

将整个方法替换为:

func _on_cards_played(player_idx: int, play: HandEvaluator.EvaluatedPlay) -> void:
    hand_area.disable_input()

    var sorted_cards: Array[Card] = []
    for c in play.cards:
        sorted_cards.append(c)
    sorted_cards.sort_custom(func(a: Card, b: Card): return a.compare_to(b) < 0)

    var card_w := 60.0
    var total_w := card_w * sorted_cards.size()
    var start_x := (1280.0 - total_w) / 2.0
    var table_y := 80.0

    var ghosts: Array[CardNode] = []
    var targets: Array[Vector2] = []

    for i in range(sorted_cards.size()):
        var src_node := _find_hand_node(sorted_cards[i])
        var src_pos := src_node.global_position if src_node else Vector2(start_x + i * card_w, 600.0)

        var ghost := CARD_NODE_SCENE.instantiate() as CardNode
        get_tree().root.add_child(ghost)
        ghost.setup(sorted_cards[i])
        ghost.global_position = src_pos
        ghost.mouse_filter = Control.MOUSE_FILTER_IGNORE
        ghosts.append(ghost)
        targets.append(Vector2(start_x + i * card_w, table_y))

    for i in range(ghosts.size()):
        var g := ghosts[i]
        var t := create_tween().set_parallel(true)
        t.tween_property(g, "global_position", targets[i], FLIGHT_DURATION) \
            .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
        t.tween_property(g, "scale", Vector2(0.7, 0.7), FLIGHT_DURATION) \
            .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)

    await get_tree().create_timer(FLIGHT_DURATION).timeout

    for g in ghosts:
        g.queue_free()
    _show_table_cards(sorted_cards)

    _show_commentary(player_idx, play)
    await get_tree().create_timer(COMMENTARY_HOLD).timeout

    var fade := create_tween()
    fade.tween_property(commentary_label, "modulate:a", 0.0, COMMENTARY_FADE_OUT)
    await get_tree().create_timer(COMMENTARY_FADE_OUT).timeout

    _clear_table_cards()
    _refresh_ui()
  • Step 3: 验证

F5 运行 → 选中任意牌 → 点击"出牌"。 预期:

  • 卡牌从手牌对应位置飞出0.3s 内落到桌面中央

  • 飞完后桌面出现 55×80 的桌牌

  • 桌面上方 CommentaryLabel 在 y=360 处淡入,显示 "玩家X 出了 TYPEN张主阶=R"

  • 0.6s 后点评淡出

  • 桌牌清掉,手牌区重建(少 N 张)

  • 飞机过程中手牌区不可点击

  • Step 4: 提交

git add src/ui/scenes/training_room.gd
git commit -m "feat(ui): flight animation + commentary on card play"

Task 6: _show_table_cards 切换父节点到 TableCardRoot

Files:

  • Modify: src/ui/scenes/training_room.gd

  • Step 1: 改 _show_table_cards() 中的 add_child 调用

定位到第 114 行(_show_table_cards 内的 add_child),将:

table_label.add_child(node)

改为:

table_card_root.add_child(node)
  • Step 2: 验证桌牌位置正确

F5 运行 → 选中任意牌 → 点击"出牌"。 预期:飞行结束后,桌牌出现在 TableCardRoot 范围内(屏幕 y=85~370位置 = Vector2(start_x + i*60, 80)。视觉上在原 TableLabel 中间偏上,与原版位置一致(因为 TableLabel 和 TableCardRoot 范围相同top=85, bottom=370子节点 y=80 都对应屏幕 y=165

  • Step 3: 提交
git add src/ui/scenes/training_room.gd
git commit -m "refactor(ui): table cards parent from TableLabel to TableCardRoot"

Task 7: 端到端手动验证

Files: 无(验证任务)

  • Step 1: 完整跑一局训练模式

F5 运行 training_room.tscn,从开始到任一玩家手牌出完。 逐项核对设计文档 §7.1 清单:

  • 27 张手牌分两行,全部可见,无横向滚动条

  • 手牌按 rank 升序2 → A → 小王 → 大王),同行内 suit 升序

  • 选中牌后点击"出牌"

    • 飞行动画流畅0.3s 内落到桌面中央
    • 卡牌从手牌位置飞出(不是凭空出现)
    • 飞完后桌面出现 55×80 的桌牌
    • 桌面上方点评淡入,文本 = "玩家X 出了 TYPEN张主阶=R"
    • 0.6s 后点评淡出
    • 桌牌清掉,手牌区重建(少 N 张)
  • 飞机过程中手牌区不可点击

  • 跑完一局完整对局,无报错、无残留节点、无僵尸 Tween

  • Step 2: 跑 AI 出牌场景

让 AI 自动出牌(玩家过牌后 AI 接手),观察:

  • AI 的 _on_cards_played 仍触发飞行 + 点评

  • 连续 AI 出牌时动画串行执行(不会并行乱套)—— 这一点依赖 await 串行化

  • 点评文字使用正确的玩家名AI 玩家名 = 玩家1/2/3

  • Step 3: 边界:选择所有 27 张牌点"出牌"

极端情况:出牌张数 = 27全部手牌飞出。 预期:动画仍然流畅;点评显示"玩家X 出了 单张27张主阶=R"或对应牌型。

  • Step 4: 检查不变性

F5 → 等手牌渲染完后,停止运行。打开调试器(如有)检查 controller.game_state.player_hands[0] 顺序与 _refresh_ui 调用前一致(本次纯 duplicate 副本排序,无破坏)。

或通过读源码确认:手牌区 update_hand 内仅 sorted_hand = hand.duplicate() + 排序,hand 数组引用未变 → GameState 未被污染。

  • Step 5: 提交(如有修复)

如果所有检查通过,无需提交。

如果发现 bug修复后追加一个 fixup commit

git add <fixed files>
git commit -m "fix(ui): <describe what you fixed>"

自审

Spec 覆盖度

Spec 章节 实施任务
§四.4.1 手牌换行FlowContainer Task 1 + Task 2
§四.4.1.2 排序逻辑 Task 2
§四.4.2.1 常量 Task 4
§四.4.2.2 @onready 引用 Task 4
§四.4.2.3 重写 _on_cards_played Task 5
§四.4.2.4 _show_commentary + _find_hand_node Task 4
§四.4.2.5 _show_table_cards 改父节点 Task 6
§五.5.1 序列时序 Task 5 验证步骤
§五.5.2 边界行为 Task 7 验证步骤
§五.5.3 不变性约束 Task 7 Step 4
§七 验证清单 Task 7

全部覆盖,无遗漏。

占位符扫描

无 TBD/TODO/"similar to"/"implement later"。所有代码块完整,命令具体。

类型一致性

  • commentary_label: Label 在 Task 3 节点定义、Task 4 @onready、Task 5 _on_cards_played 中三处出现 → 类型一致
  • table_card_root: Control 在 Task 3 节点定义、Task 4 @onready、Task 6 _show_table_cards 中三处出现 → 类型一致
  • FLIGHT_DURATION 在 Task 4 定义、Task 5 使用 → 一致
  • _show_commentary(player_idx, play) 签名在 Task 4 定义、Task 5 调用 → 一致
  • _find_hand_node(card: Card) -> CardNode 签名在 Task 4 定义、Task 5 使用 → 一致
  • var ghosts: Array[CardNode]ghost: CardNode(来自 CARD_NODE_SCENE.instantiate() as CardNode)→ 一致

无类型不一致。


计划完成。 共 7 个任务,预计 7 次提交。每次提交都是可独立回滚的小步增量。