docs: 首帧锁定尖峰决策为「接受」+ Task4 Step7 改为两场景单帧峰值回归守卫

spec §5.6:残余的首帧尖峰(1500 弹同帧创建且首次选目标全失败时 16~22ms)
决策接受不处理——触发条件是三个苛刻条件的合取属合成最坏情况、一次性不复发、
压下它需牺牲「首次锁定即时」的手感或改变归航/bounce 语义。附重新立项触发条件。

plan Task4 Step7:原只测「敌人全在射程外」,补上评审发现的更严重场景
「射程内全在 visited」(visited 只增不减故为永久状态);指标由 60 帧均值
改为单帧峰值——退避把成本摊成 1 重选帧 + 11 退避帧,均值会掩盖尖峰,
而帧预算 16.67ms 下尖峰才是玩家可见的。性质由决策关卡降级为回归守卫。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 16:15:01 +08:00
co-authored by Claude Opus 5
parent 5cc40e72e8
commit 4ef8012b6e
2 changed files with 50 additions and 20 deletions
+40 -18
View File
@@ -457,29 +457,51 @@ _mcp_print("FAILS=%d folded=%.2f after_reset=%.2f in_pool=%s" %
Expected: `FAILS=0 folded=6.00 after_reset=0.00 in_pool=true`
> API 已确认存在:`SpellRegistry.get_spell(id)`:36)、`get_all_ids()`:39)、`CastStats` 为 `class_name` 注册的 RefCounted(可 `.new()`)。
- [ ] **Step 7: 性能守卫 —— 确认未触发 R-H1 尖峰**
- [ ] **Step 7: 性能回归守卫 —— R-H1 两种病态场景的单帧峰值**
> spec §5 记录的风险:「敌人在场但均超出 `homing_range`」时每颗归航子弹每帧都会 `query_circle`。此步实测该病态场景,决定是否启用逃生方案(方案 3 失败退避)
> **本步性质已变更(2026-07-30,Task 2 代码质量评审后)**:原本这是「决策关卡」—— 实测后再定要不要启用 spec §5 的退避方案。评审在 Task 2 阶段就直接实测并**证实触发**(300 弹规模即越过 2.0 ms 线),故退避 + 抖动**已随 Task 2 落地**。本步因此降级为**回归守卫**:确认退避与抖动仍生效、峰值未回弹
>
> **两种病态场景都要测**(原计划只写了第一种,漏了更糟的第二种):
> 1. **敌人在场但均超出 `homing_range`** —— 玩家朝空处/刚清空的方向开火。瞬态。
> 2. **射程内敌人全在 `visited_targets` 里** —— bounce+homing 组合弹打空一个小簇后。`visited` 只增不减,故这对那颗子弹是**永久状态**,比第一种更严重。这条是评审发现的,原 spec 风险分析漏掉了。
>
> **指标是单帧最大值,不是 60 帧均值。** 退避把成本摊薄成「1 个重选帧 + 11 个退避帧」,均值会掩盖重选帧的尖峰;而帧预算只有 16.67 ms,尖峰才是会被玩家看见的东西。抖动的作用正是打散这个尖峰。
Run: godot-mcp-pro `execute_game_script`
Run: godot-mcp-pro `execute_game_script`(两种场景各跑一次,改 `SCENARIO` 常量)
```gdscript
var SCENARIO := 1 # 1 = 敌人全在射程外;2 = 射程内全 visited
var N_BULLETS := 300 # 也跑一遍 1500 作为压力上限
BulletManager.reset(); EnemyManager.reset(); SpatialGrid.clear()
# 敌人全部远在 homing_range(400) 之外 → 每帧重选且每帧失败
for k in 50:
var eid = EnemyManager.spawn_enemy(Vector2(3000 + k * 10, 0), 1000.0, 0)
SpatialGrid.insert(eid, Vector2(3000 + k * 10, 0))
for k in 300:
BulletManager.spawn_bullet(Vector2(0, k), Vector2(350, 0), 10.0, 6.0, 1.0, 1.0, 0, -1, 0, 0.0,
{"homing_strength": 3.0, "homing_range": 400.0, "homing_target_id": -1})
var t0 := Time.get_ticks_usec()
for k in 60:
var visited := []
if SCENARIO == 1:
for k in 50: # 全部远在 homing_range(400) 之外
var eid = EnemyManager.spawn_enemy(Vector2(3000 + k * 10, 0), 1000.0, 0)
SpatialGrid.insert(eid, Vector2(3000 + k * 10, 0))
else:
for k in 30: # 在射程内,但全部预置进 visited → 重选必然失败
var eid = EnemyManager.spawn_enemy(Vector2(200 + k, 100 + k), 1000.0, 0)
SpatialGrid.insert(eid, Vector2(200 + k, 100 + k))
visited.append(eid)
for k in N_BULLETS:
var cold := {"homing_strength": 3.0, "homing_range": 400.0, "homing_target_id": -1}
if SCENARIO == 2:
cold["visited_targets"] = visited.duplicate()
BulletManager.spawn_bullet(Vector2(0, k), Vector2(350, 0), 30.0, 6.0, 1.0, 1.0, 0, -1, 0, 0.0, cold)
# 跑满一个退避周期以上,捕捉单帧峰值(含重选帧)
var peak_us := 0
var total_us := 0
for k in 40:
var t0 := Time.get_ticks_usec()
BulletManager._gd_integrate(1.0 / 60.0)
var per_frame := float(Time.get_ticks_usec() - t0) / 60.0 / 1000.0 # ms/帧
_mcp_print("per_frame_ms=%.3f threshold=2.000 escape_needed=%s" %
[per_frame, str(per_frame > 2.0)])
var dt_us := Time.get_ticks_usec() - t0
total_us += dt_us
if dt_us > peak_us: peak_us = dt_us
_mcp_print("scenario=%d bullets=%d peak_ms=%.3f mean_ms=%.3f budget=16.670 over_budget=%s" %
[SCENARIO, N_BULLETS, peak_us / 1000.0, total_us / 40.0 / 1000.0, str(peak_us / 1000.0 > 16.67)])
```
Expected: `escape_needed=false``per_frame_ms` 应远低于 2.0
`escape_needed=true`**按 spec §5 启用方案 3**`_apply_homing` 重选失败时写 `cold["homing_retry_at"] = Time.get_ticks_msec() + 200`,重选前先判 `Time.get_ticks_msec() < int(cold.get("homing_retry_at", 0))` 则直接 return;重选成功时抹掉该键。然后重跑本步确认降到阈值以下,并在 spec §5 记录实测数据与启用结论
Expected300 弹两种场景 `peak_ms` 均明显低于 2.0;1500 弹(压力上限)`over_budget=false`
> 若 `over_budget=true`**停下来上报,不要自行加码** —— 那说明需要的是设计级方案(给 `visited_targets` 设上限、或让归航子弹连续失败 N 次后彻底放弃归航),属设计决策而非实现调优
> 注:编辑器/调试构建的绝对值偏高于 release,看**比例与是否越线**而非绝对数字。基线(普通弹、无冷数据)约 1.6 ms @1500 / 0.32 ms @300,可先跑一次作参照。
- [ ] **Step 8: 设计器字段回读往返(验收⑨后半)**
@@ -537,4 +559,4 @@ EOF
| 8 | 两层修饰器 → `homing_add == 6.0` | Task 4 Step 6 |
| 9 | 商店可购 + 设计器字段往返 | Task 4 Step 6 / Step 8 |
| 10 | `validate_script` 通过 / 无目标不崩 | Task 1 Step 6、Task 2 Step 5、Task 3 Step 2、Task 4 Step 4 |
| — | R-H1 风险实测与逃生方案判定(spec §5 | Task 4 Step 7 |
| — | R-H1 退避+抖动的性能回归守卫(两种病态场景 × 单帧峰值,spec §5 | Task 4 Step 7 |
@@ -252,11 +252,19 @@ MODIFIER`type 1`)的 meta schema 加:
复发尖峰彻底消除,稳态全部回到帧预算内。
### 5.6 残余:初次锁定帧(待决策,非退避问题)
### 5.6 残余:初次锁定帧 —— **已决策:接受,不处理**
抖动后仍有**一个**高耗时帧 —— 第 1 帧,1500 弹 `far` 16.2 ms / `visited` 22.0 ms。它**不是**退避尖峰,而是「§2.4 首次锁定即时、不受退避约束」的直接结果:1500 颗归航子弹在同一帧被创建时,它们的首次 `query_circle` 必然落在同一帧。抖动按设计不作用于此(已断言首帧即时锁定且不写退避键,手感不回退)。
触发条件苛刻:需**同一帧**创建约 1500 颗归航子弹且首次选目标全部失败。典型局面下齐射规模远小于此,且失败还需叠加「全射程外」或「全 visited」。**未擅自处理** —— 压下它必须牺牲「首次锁定即时」这一手感属性(如首帧也抖动、或连续失败 N 次后彻底放弃归航),属设计决策而非性能调优,留待需要时立项。
触发条件苛刻:需**同一帧**创建约 1500 颗归航子弹且首次选目标全部失败。典型局面下齐射规模远小于此,且失败还需叠加「全射程外」或「全 visited」。
**决策(2026-07-30):接受,本期不处理。** 理由三条:
1. **触发条件不是真实局面。** 1500 弹是全游戏子弹池的压力上限(`MAX_BULLETS=2048`),要求它们**全部**归航、**同帧**创建、且首次选目标**全部**失败 —— 这是三个独立苛刻条件的合取,属合成的最坏情况而非可达的玩法状态。项目实测基准是 500 敌 + 1500 弹时整个 `_physics_process` ≈ 0.6 ms(帧预算 3.6%)。
2. **它一次性、不复发。** 与被修掉的那个每 12 帧永久复发的尖峰性质不同 —— 后者是持续卡顿,前者最多是一次可忽略的掉帧。
3. **压下它的代价是手感回退或语义变更。** 三条可行路径(首帧也抖动 / 连续失败 N 次后彻底放弃归航 / 给 `visited_targets` 设上限)分别牺牲「首次锁定即时」、改变归航语义、改变 bounce 语义。按 CLAUDE.md「先测量后优化,不做无凭据的过早优化」,为一个合成场景付这个代价不划算。
**重新立项的触发条件**:若日后出现「单帧创建数百颗以上归航子弹」的真实玩法(如某个 Endless 全局词条或 Boss Rush 构筑),且实测首帧超 16.67 ms,则按上述三条路径之一立项处理。
## 6. 非目标(YAGNI