14 KiB
14 KiB
手牌换行 + 出牌动画 + 点评 — 设计规格
日期: 2026-06-02
范围: 训练模式 UI 改进
相关模块: src/ui/components/hand_area.*、src/ui/scenes/training_room.*
一、目标
解决训练模式当前三个 UX 问题:
- 手牌溢出: 起手 27 张牌在 1280×720 视口下,水平方向硬滚动 2200px 才能看完,竖直方向空闲(320px 高度只用了 110px)。
- 出牌零反馈:
_show_table_cards()直接position =赋值给桌牌,玩家无视觉引导,从手牌"跳"到桌面。 - 缺点评信息: 玩家只能从
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(场景根节点):
-[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 也要改):
-[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 个并行 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 增强(点评)符合训练模式范围 |