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

14 KiB
Raw Blame History

手牌换行 + 出牌动画 + 点评 — 设计规格

日期: 2026-06-02 范围: 训练模式 UI 改进 相关模块: src/ui/components/hand_area.*src/ui/scenes/training_room.*


一、目标

解决训练模式当前三个 UX 问题:

  1. 手牌溢出: 起手 27 张牌在 1280×720 视口下,水平方向硬滚动 2200px 才能看完竖直方向空闲320px 高度只用了 110px
  2. 出牌零反馈: _show_table_cards() 直接 position = 赋值给桌牌,玩家无视觉引导,从手牌"跳"到桌面。
  3. 缺点评信息: 玩家只能从 status_label 看到 "玩家X 出了 牌型N张",没有主阶、动作质量等结构化信息。

实现后达到:

  • 手牌自动两行(极端窄屏可滚动至 3+ 行)
  • 出牌有"飞牌 + 缩放"动画
  • 桌面上方有"基础点评"浮层(牌型 + 张数 + 主阶)

二、范围与非目标

范围内

  • hand_area.tscn / hand_area.gdHBoxContainer → FlowContainer手牌渲染前按 rank+suit 排序
  • training_room.tscn / training_room.gd:新增 CommentaryLabel + TableCardRoot重写 _on_cards_played() 串行执行"飞行 → 桌牌 → 点评 fade"
  • 验证:手动跑 Godot + 截图比对

非目标YAGNI

  • 扇形/重叠手牌布局(设计文档 8.1.1 描述的「重叠率 50-65%」)—— 工作量与改动面远超本次范围
  • AI 评分驱动的增强点评("✓ 优解 / ✗ 浪费" 标签)—— 留作后续
  • 音效(AudioManager.play_card_place() 仍为 pass)—— 与本次无耦合
  • 复杂的多张牌并发动画(炸弹/连对的"分组飞入")—— 本次只做线性整体飞行
  • 移动端拖拽框选、V-Filtering、智能理牌折叠 —— 后续 UI 阶段

三、架构

UI 层 (Godot)              业务逻辑层 (core/)
─────────                  ────────────────
training_room.gd           GameState (零改动)
  ├─ _on_cards_played()
  │   ├─ 取源位置 (hand_area.card_nodes)
  │   ├─ ghost 飞行 tween
  │   ├─ 桌牌替换 ghost
  │   ├─ 点评 fade in
  │   └─ 点评 fade out + 清桌 + 刷新手牌
  └─ _show_commentary()
      └─ 直接读 EvaluatedPlay {type, cards, primary_rank}
                          hand_evaluator.gd (零改动)
                          rule_engine.gd (零改动)

约束: 业务逻辑层零改动。动画/点评是纯表现,违反此约束的设计直接否决。


四、详细设计

4.1 手牌换行FlowContainer

4.1.1 场景文件改动

src/ui/components/hand_area.tscn(场景根节点):

-[node name="HandArea" type="HBoxContainer"]
-offset_right = 800.0
-offset_bottom = 150.0
-script = ExtResource("1_script")
+[node name="HandArea" type="FlowContainer"]
+script = ExtResource("1_script")

src/ui/scenes/training_room.tscnHandArea 实例的 type 也要改):

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

src/ui/scenes/training_room.tscn — ScrollContainer 区域:

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

新增节点:

[node name="CommentaryLabel" type="Label" parent="."]
layout_mode = 0
offset_top = 360.0
offset_right = 1280.0
offset_bottom = 380.0
horizontal_alignment = 1
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

4.1.2 脚本改动

src/ui/components/hand_area.gd

class_name HandArea
extends FlowContainer  # HBoxContainer → FlowContainer
# ... 字段、信号、其它方法保持不变 ...

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

    # 副本排序,不污染 GameState.player_hands
    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)

为什么副本排序: 设计文档 §一"禁止共享引用"约束。所有 getter 返回副本或只读包装,手牌区显示需要排序则不能直接修改 GameState.player_hands[0]

4.2 出牌动画 + 点评

4.2.1 常量

src/ui/scenes/training_room.gd 顶部:

const FLIGHT_DURATION := 0.30   # 飞行 + 缩放时长
const COMMENTARY_FADE_IN := 0.20
const COMMENTARY_HOLD   := 0.60
const COMMENTARY_FADE_OUT := 0.30

4.2.2 新增 @onready 引用

@onready var commentary_label: Label = $CommentaryLabel
@onready var table_card_root: Control = $TableCardRoot

4.2.3 _on_cards_played() 重写

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

    # 1. 排序(与现有 _show_table_cards 行为一致)
    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)

    # 2. 计算目标位置(中央对齐)
    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   # table_card_root 内坐标(与原 _show_table_cards 的 y=80 保持一致)

    # 3. 生成 ghost 卡牌 + 飞行 tween
    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)  # 关键:加到根节点,不受 FlowContainer 重排
        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

    # 4. 收起 ghost桌牌出现
    for g in ghosts:
        g.queue_free()
    _show_table_cards(sorted_cards)

    # 5. 点评 fade in
    _show_commentary(player_idx, play)
    await get_tree().create_timer(COMMENTARY_HOLD).timeout

    # 6. 点评 fade out
    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

    # 7. 清桌 + 刷新手牌
    _clear_table_cards()
    _refresh_ui()

4.2.4 新增辅助方法

func _show_commentary(player_idx: int, play: HandEvaluator.EvaluatedPlay) -> void:
    if not commentary_label:
        return
    var name := controller.game_state.player_names[player_idx]
    var type_name := _get_type_name(play.type)
    commentary_label.text = "%s 出了 %s%d张,主阶=%d" \
        % [name, 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

4.2.5 _show_table_cards() 微调

-table_label.add_child(node)
+table_card_root.add_child(node)

其余不变(仍按 55×80 桌牌尺寸渲染)。


五、行为规格

5.1 序列时序

T=0.0s   cards_played 信号触发
         ├─ hand_area.disable_input()
         ├─ 创建 N 个 ghost 节点global_position = 源手牌节点位置)
         └─ 启动 N 个并行 tweenposition + scale

T=0.3s   ghost 飞行结束
         ├─ 销毁所有 ghost
         ├─ _show_table_cards() 创建桌牌(位置 = targets
         └─ 启动 commentary fade in

T=0.5s   commentary 完全可见

T=1.1s   commentary 开始 fade out

T=1.4s   commentary 完全隐藏
         ├─ _clear_table_cards() 销毁桌牌
         └─ _refresh_ui() 重建手牌区27-张数)

T=1.4s+  hand_area.enable_input() 由 turn_ready 信号在下个回合触发

5.2 边界行为

场景 行为
玩家在动画期间关掉场景 _exit_tree() 自动 kill() 所有活跃 tween设计文档 §四ghost 节点随父节点释放
AI 紧接玩家出牌触发 _on_cards_played await 串行执行AI 的 handler 等候直到当前 handler 的 1.4s 流程结束。无并发状态
玩家选择非法牌型 controller.handle_human_play() 早于 cards_played 校验失败,_on_cards_played 不会触发,零影响
_find_hand_node 找不到对应手牌节点 兜底:使用兜底位置 Vector2(start_x + i*60, 600),不抛错
极端窄屏(手牌 3+ 行) vertical_scroll_mode = 2 启用FlowContainer 超出 ScrollContainer 高度时竖向滚动

5.3 不变性约束

  • controller.game_state.player_hands[0] 的牌序在 update_hand() 排序前后完全一致update_handduplicate(false) + 本地排序)
  • EvaluatedPlaycards 字段在 _on_cards_played() 内被 duplicate(false),不修改 controller 持有的引用
  • ghost 节点生命周期限定在 _on_cards_played 作用域内,函数返回前必须 queue_free 全部 ghost

六、文件清单

状态 路径 改动量
修改 src/ui/components/hand_area.tscn 1 行(容器类型 + 2 个属性)
修改 src/ui/components/hand_area.gd 1 行extends+ 4 行(排序逻辑)
修改 src/ui/scenes/training_room.tscn 3 行ScrollContainer 高度+滚动模式)+ 12 行(新节点)
修改 src/ui/scenes/training_room.gd 新增 ~50 行(动画 + 点评),重写 _on_cards_played ~30 行
不变 src/core/* 0
不变 src/ai/* 0
不变 src/game/* 0
不变 src/autoload/* 0
不变 assets/* 0
不变 tests/* 0

总计: 4 个文件,预计净增 ~70 行 GDScript + 16 行 .tscn。


七、验证

7.1 手动验证清单

打开 Godot → 运行 training_room.tscn

  • 起手 27 张牌自动分两行,全部可见,无横向滚动条
  • 手牌按 rank 升序2 → A → 小王 → 大王),同行内 suit 升序
  • 选中牌后点击"出牌",观察:
    • 飞行动画流畅,无卡顿
    • 卡牌从手牌对应位置飞出0.3s 内落到桌面中央
    • 飞完后桌面出现 55×80 的桌牌
    • 桌面上方点评文字淡入,显示"玩家X 出了 TYPEN张主阶=R"
    • 0.6s 后点评淡出
    • 桌牌清掉,手牌区重建(少 N 张)
  • 飞机过程中手牌区不可点击
  • 跑完一局完整对局,无报错、无残留节点

7.2 自动化检查

不新增 GUT 用例(与设计文档 §五"UI 测试:手动 + 截图对比"一致)。

7.3 风险监控

  • Tween 残留:_exit_tree 未清理 tween可能导致 ghost 节点在场景销毁后继续触发 tween_property 写入。Godot 4.x tween 在父节点 free 时自动 kill(),但显式 kill() 更稳。本次依赖 Godot 默认行为,不额外加保险。
  • FlowContainer 重排抖动: 出牌后 _refresh_ui 重建手牌区时FlowContainer 会重新计算布局,可能在 1 帧内出现位置跳变。本次的"飞行 + 缩放"动画用 0.3s 覆盖此跳变,玩家感知不到。

八、与设计文档既有约束的一致性

设计文档条款 本次设计是否合规
§一「禁止共享引用」 duplicate(false) 后排序
§一「Tween 清理:_exit_treestop_all() + kill() ✓ Godot 4.x 默认行为;不修改既有 training_room.gd_exit_tree(无新增)
§四「核心层不引用 Godot 节点类」 ✓ 零 core/ 改动
§四「UI 节点销毁时断 EventBus 连接」 ✓ 本次不新增 EventBus 订阅
§八.1.1「手牌按 Rank→Suit 排序后均匀分布」 ✓ 4.1.2 sort_custom
§八「UI Transition Lock动画期间锁住输入」 hand_area.disable_input()
§六.1「训练模式优先完成:核心逻辑 + L1/L2 AI + 牌型提示 UI」 ✓ 牌型提示 UI 增强(点评)符合训练模式范围