From 2b2b76c8675f95f22ee103282aedb5cf8c771873 Mon Sep 17 00:00:00 2001 From: xiaji Date: Tue, 2 Jun 2026 19:28:54 +0800 Subject: [PATCH] docs: add design spec for hand wrap + play animation + commentary --- ...-02-hand-wrap-and-play-animation-design.md | 377 ++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-hand-wrap-and-play-animation-design.md diff --git a/docs/superpowers/specs/2026-06-02-hand-wrap-and-play-animation-design.md b/docs/superpowers/specs/2026-06-02-hand-wrap-and-play-animation-design.md new file mode 100644 index 0000000..060db38 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-hand-wrap-and-play-animation-design.md @@ -0,0 +1,377 @@ +# 手牌换行 + 出牌动画 + 点评 — 设计规格 + +**日期:** 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 增强(点评)符合训练模式范围 |