docs: add design spec for hand wrap + play animation + commentary
This commit is contained in:
@@ -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 增强(点评)符合训练模式范围 |
|
||||||
Reference in New Issue
Block a user