# 手牌换行 + 出牌动画 + 点评 — 设计规格 **日期:** 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.gd`:HBoxContainer → 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`(场景根节点): ```diff -[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.tscn`(HandArea 实例的 type 也要改): ```diff -[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 区域: ```diff [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 ``` 新增节点: ```ini [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`: ```gdscript 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` 顶部: ```gdscript 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 引用 ```gdscript @onready var commentary_label: Label = $CommentaryLabel @onready var table_card_root: Control = $TableCardRoot ``` #### 4.2.3 `_on_cards_played()` 重写 ```gdscript 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 新增辅助方法 ```gdscript 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()` 微调 ```diff -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 个并行 tween(position + 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_hand` 内 `duplicate(false)` + 本地排序) - `EvaluatedPlay` 的 `cards` 字段在 `_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 出了 TYPE(N张,主阶=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_tree` 中 `stop_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 增强(点评)符合训练模式范围 |