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

533 lines
17 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.
# 手牌换行 + 出牌动画 + 点评 — 实施计划
> **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`,将整个文件内容替换为:
```ini
[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 节点),将:
```ini
[node name="HandArea" type="HBoxContainer" parent="ScrollContainer"]
layout_mode = 2
size_flags_horizontal = 0
script = ExtResource("2_script")
custom_minimum_size = Vector2(2200, 140)
```
改为:
```ini
[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 节点),将:
```ini
[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
```
改为:
```ini
[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高 280`Buttons` HBoxContainer 在 540600会被覆盖。将第 53-58 行:
```ini
[node name="Buttons" type="HBoxContainer" parent="."]
layout_mode = 0
offset_top = 540.0
offset_right = 1280.0
offset_bottom = 600.0
alignment = 1
```
改为:
```ini
[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: 提交**
```bash
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: 改 `extends` 与 `update_hand()`**
打开 `src/ui/components/hand_area.gd`,将:
```gdscript
class_name HandArea
extends HBoxContainer
```
改为:
```gdscript
class_name HandArea
extends FlowContainer
```
然后定位到 `update_hand()` 方法(第 15-28 行),将整个方法替换为:
```gdscript
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: 提交**
```bash
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 节点块的下方追加:
```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
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` 下新增 `CommentaryLabel``TableCardRoot` 两个节点F5 运行无报错。
- [ ] **Step 3: 提交**
```bash
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` 之后)新增:
```gdscript
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`),在其后追加:
```gdscript
@onready var commentary_label: Label = $CommentaryLabel
@onready var table_card_root: Control = $TableCardRoot
```
- [ ] **Step 3: 在 `_clear_table_cards()` 之后追加两个辅助方法**
定位到第 122-125 行(`_clear_table_cards()` 方法),在其后追加:
```gdscript
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: 提交**
```bash
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` 方法):
```gdscript
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: 替换为新实现**
将整个方法替换为:
```gdscript
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: 提交**
```bash
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`),将:
```gdscript
table_label.add_child(node)
```
改为:
```gdscript
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: 提交**
```bash
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
```bash
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 次提交。每次提交都是可独立回滚的小步增量。