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:
@@ -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 记录实测数据与启用结论。
|
||||
Expected:300 弹两种场景 `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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user