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

378 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 手牌换行 + 出牌动画 + 点评 — 设计规格
**日期:** 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 个并行 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_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 出了 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_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 增强(点评)符合训练模式范围 |