506 lines
16 KiB
Markdown
506 lines
16 KiB
Markdown
|
|
# 手牌换行 + 出牌动画 + 点评 — 实施计划
|
|||
|
|
|
|||
|
|
> **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.x(FlowContainer、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 改 FlowContainer;update_hand 排序 |
|
|||
|
|
| 修改 | `src/ui/scenes/training_room.tscn` | ScrollContainer 高度/模式;新增 CommentaryLabel + TableCardRoot;HandArea 实例 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 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
|
|||
|
|
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 出了 TYPE(N张,主阶=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 出了 TYPE(N张,主阶=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 次提交。每次提交都是可独立回滚的小步增量。
|