fix(attr): cast_delay_mod 硬上限 0.01 → 0.05,消掉两把主力杖的射速无效区间

_handle_auto_cast 用 if 而非 while,每物理帧至多施法一次,故本属性存在饱和点
= (1/60) / 法杖基准间隔:wand_basic(0.5s) ≈ 0.033、wand_fast(0.25s) ≈ 0.05。
原 hard: 0.01 在两把主力杖上都深深落在饱和区内——玩家把 cast_delay_mod 从
0.033 一路买到 0.01,射速一点没变,属"承诺了循环交付不了的东西"。

取 0.05 使 wand_fast 恰在硬上限处饱和、wand_basic 全程无无效区间。
边界实测(同一套 _physics_process 帧距扫描):wand_fast@0.05 = 1 帧/次(60/s)、
wand_basic@0.05 = 2 帧/次(30/s)、请求 0.01 被 maxf(hard,…) 钳回 0.05 后帧距不变、
soft(0.1) 对照仍 3 帧/次(20/s)、circuit_fork@0.05 = 2 帧/次。

未加任何代码钳制:下限已由 attributes.json + AttributeFormula 的 maxf(hard,…)
强制,代码里再加一个会形成重复权威,设计师调 hard 时立刻脱同步。

spec 新增 §2.2b 收录完整实测数据集(饱和点公式与三把杖的值、soft→hard 是台阶
而非曲线、周期恒为 ceil(T×60) 且整帧倍数多花一帧故实际射速恒 ≤ 名义值)——
结论易得而数据难得,故一并留档。circuit_fork 的饱和点 0.0278 标注为公式推导。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 13:18:46 +08:00
co-authored by Claude Opus 5
parent d259205db2
commit ba7c885291
4 changed files with 105 additions and 7 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"cpu_limit": { "display_name": "运算力 cpu_limit", "base": 0.0, "soft": 20.0, "hard": 50.0, "combine": "add_int" }, "cpu_limit": { "display_name": "运算力 cpu_limit", "base": 0.0, "soft": 20.0, "hard": 50.0, "combine": "add_int" },
"move_speed": { "display_name": "移动速度 move_speed", "base": 200.0, "soft": 600.0, "hard": 800.0, "combine": "hybrid" }, "move_speed": { "display_name": "移动速度 move_speed", "base": 200.0, "soft": 600.0, "hard": 800.0, "combine": "hybrid" },
"cast_delay_mod": { "display_name": "施法延迟 cast_delay_mod", "base": 1.0, "soft": 0.1, "hard": 0.01, "combine": "inverse" }, "cast_delay_mod": { "display_name": "施法延迟 cast_delay_mod", "base": 1.0, "soft": 0.1, "hard": 0.05, "combine": "inverse" },
"hp_max": { "display_name": "最大生命 hp_max", "base": 100.0, "soft": 2000.0, "hard": 0.0, "combine": "hybrid" } "hp_max": { "display_name": "最大生命 hp_max", "base": 100.0, "soft": 2000.0, "hard": 0.0, "combine": "hybrid" }
} }
+1 -1
View File
@@ -18,7 +18,7 @@
| `hp_max` | 最大生命 | 100 | 2000 | - | | | `hp_max` | 最大生命 | 100 | 2000 | - | |
| `mana_max` | 最大魔力 | 100 | 1000 | - | 全局蓝条上限,法杖也有自己的上限,取 Min 值 | | `mana_max` | 最大魔力 | 100 | 1000 | - | 全局蓝条上限,法杖也有自己的上限,取 Min 值 |
| `move_speed` | 移动速度 | **200** | 600 | 800 | 像素/秒。⚠️ **2026-07-31 订正**:原写 300 从未被任何代码读取过,是纸面孤值;实际实现自 S0 起即为 200(`player_manager.gd``const MOVE_SPEED`,现已改为读 `data/attributes.json`),20 波内容、Boss 弹幕密度、无敌帧 0.5 s 均按此值调校。此处以既成事实为准 —— 改回 300 等于用未经验证的数字推翻一整轮已调校的内容。理由详见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2。 | | `move_speed` | 移动速度 | **200** | 600 | 800 | 像素/秒。⚠️ **2026-07-31 订正**:原写 300 从未被任何代码读取过,是纸面孤值;实际实现自 S0 起即为 200(`player_manager.gd``const MOVE_SPEED`,现已改为读 `data/attributes.json`),20 波内容、Boss 弹幕密度、无敌帧 0.5 s 均按此值调校。此处以既成事实为准 —— 改回 300 等于用未经验证的数字推翻一整轮已调校的内容。理由详见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2。 |
| `cast_delay_mod` | 施法延迟修正 | 1.0 (100%) | 0.1 | 0.01 | 越低越快,乘算系数 | | `cast_delay_mod` | 施法延迟修正 | 1.0 (100%) | 0.1 | **0.05** | 越低越快,乘算系数。⚠️ **2026-07-31 由 0.01 上调至 0.05**`player_manager._handle_auto_cast``if` 而非 `while`,**每物理帧至多施法一次**,故本属性存在**饱和点** = `(1/60) / 法杖基准间隔` —— `wand_basic`(0.5 s) ≈ 0.033、`wand_fast`(0.25 s) ≈ 0.05。原 `0.01` 在两把主力杖上都深深落在饱和区内,即玩家买到 0.033 以下**再也换不到任何射速**,属"承诺了循环交付不了的东西"。取 0.05 使 `wand_fast` 恰好在硬上限处饱和、`wand_basic` 全程无无效区间。实测数据(可达档位是台阶而非曲线、周期恒为 `ceil(T×60)`)见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2b。 |
| `recharge_speed_mod` | 充能速度修正 | 1.0 (100%) | 5.0 | - | 越低越快 | | `recharge_speed_mod` | 充能速度修正 | 1.0 (100%) | 5.0 | - | 越低越快 |
| `luck` | 幸运 | 0 | 100 | - | 影响高阶法术掉率、暴击率 | | `luck` | 幸运 | 0 | 100 | - | 影响高阶法术掉率、暴击率 |
| `cpu_limit` | 运算力上限 | **0**(玩家侧) | 20 | 50 | `SpellEvaluator` 实际 MAX_OPS = `生效 cpu_limit × 40`,与 `implementation_plan.md §2.2``MAX_OPS_PER_CPU = 40` 常量对应。**生效值 = 玩家侧基准 0 + 法杖份额(`cores.json` 逐杖 38,以 `source="core"` 的加成来源接入)+ 未来的玩家加成**;`hard`(50) 作用于**加总后的生效值**,故法杖份额也受硬上限约束。玩家侧基准取 0 而非 5 是刻意的:生效值已含法杖的 3–8,取 0 使小木法杖仍为 5 → 200 步(现有平衡零改动),取 5 会让所有法杖的执行预算翻倍;语义上玩家属性是"全局加成",基准 0 正是纯加成语义。 ✅ **2026-07-31 已修复**(原 ⚠️「代码硬编码 MAX_OPS = 40×5 = 200,从不读 cpu_limit」现已失效):`spell_evaluator.gd` 改读 `MAX_OPS_PER_CPU * maxi(PlayerStats.cpu_limit, 1)``cores.json` 逐杖配置的运算力自此生效(高速法杖 3 → 120 步、小木 5 → 200、矩阵板 8 → 320,运行时实测)。设计见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2。 | | `cpu_limit` | 运算力上限 | **0**(玩家侧) | 20 | 50 | `SpellEvaluator` 实际 MAX_OPS = `生效 cpu_limit × 40`,与 `implementation_plan.md §2.2``MAX_OPS_PER_CPU = 40` 常量对应。**生效值 = 玩家侧基准 0 + 法杖份额(`cores.json` 逐杖 38,以 `source="core"` 的加成来源接入)+ 未来的玩家加成**;`hard`(50) 作用于**加总后的生效值**,故法杖份额也受硬上限约束。玩家侧基准取 0 而非 5 是刻意的:生效值已含法杖的 3–8,取 0 使小木法杖仍为 5 → 200 步(现有平衡零改动),取 5 会让所有法杖的执行预算翻倍;语义上玩家属性是"全局加成",基准 0 正是纯加成语义。 ✅ **2026-07-31 已修复**(原 ⚠️「代码硬编码 MAX_OPS = 40×5 = 200,从不读 cpu_limit」现已失效):`spell_evaluator.gd` 改读 `MAX_OPS_PER_CPU * maxi(PlayerStats.cpu_limit, 1)``cores.json` 逐杖配置的运算力自此生效(高速法杖 3 → 120 步、小木 5 → 200、矩阵板 8 → 320,运行时实测)。设计见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2。 |
+15 -4
View File
@@ -1026,11 +1026,22 @@ Run: godot-mcp-pro `stop_scene`,然后 `execute_editor_script` 扫四个文件
3. **soft→hard 整段只有 3 个可达档位**`wand_basic`20 / 30 / 60 次每秒),**`wand_fast` 只有 2 个**(30 / 60)—— 快端是台阶不是平滑曲线。 3. **soft→hard 整段只有 3 个可达档位**`wand_basic`20 / 30 / 60 次每秒),**`wand_fast` 只有 2 个**(30 / 60)—— 快端是台阶不是平滑曲线。
4. 帧距总是 `ceil(T × 60)`,且 `T` 恰为整帧倍数时会**多一帧**`T=0.5` 得 31 而非 30`T=0.25` 得 16 而非 15):`_cast_timer` 是赋值,浮点余量使它多跑一帧。故**实际速率永远 ≤ 名义速率**,不会超发。 4. 帧距总是 `ceil(T × 60)`,且 `T` 恰为整帧倍数时会**多一帧**`T=0.5` 得 31 而非 30`T=0.25` 得 16 而非 15):`_cast_timer` 是赋值,浮点余量使它多跑一帧。故**实际速率永远 ≤ 名义速率**,不会超发。
**两个选项,需人定** **两个选项曾提交人定**(a) 保持 `hard: 0.01` 并在文档写明饱和区;(b) 把 `hard` 提到 ~0.05。
- (a) 保持 `hard: 0.01`,在 `numerical_design.md``attributes.json` 注释里写明「低于 `(1/60)/基准间隔` 为饱和区,不再提升实际射速」;
- (b) 把 `hard` 提到 ~0.05(约等于最慢法杖的饱和点),让 JSON 承诺的是真能交付的东西。
**不要在代码里加下限钳制** —— 下限已由 `attributes.json` + `AttributeFormula``maxf(hard, ...)` 强制,在 `player_manager` 再加一个会形成重复权威,设计师调 `hard` 时立刻脱同步,违反「纯数据驱动,代码不含硬编码副本或回退」。**本步只上报数字,未改 `hard`、未加钳制。** **✅ 用户 2026-07-31 决定:采用 (b)`hard: 0.01 → 0.05`。** 依据正是上表 —— `wand_fast` 恰在 0.05 饱和、`wand_basic` 在 0.0333 饱和,取 0.05 使两把主力杖都不再有「买了没用」的无效区间。落地三处:`data/attributes.json``numerical_design.md` §1.1、spec 新增 §2.2b(收录完整实测数据集)。
**边界复测**(改动后用同一套扫描重跑,这是本次改动唯一的行为差异,值得实测而非推导):
| 法杖 | 请求 `mod` | 生效 `mod` | T | 实测帧距 | 速率 |
| :-- | --: | --: | --: | --: | --: |
| `wand_fast` | 0.05 | 0.05 | 0.0125 s | **1 帧×179** | 60 /s(恰好饱和,不浪费)|
| `wand_fast` | 0.01 | **0.05** | 0.0125 s | 1 帧×179 | 60 /s`maxf(hard,…)` 钳制生效)|
| `wand_basic` | 0.05 | 0.05 | 0.025 s | **2 帧×89** | 30 /s |
| `wand_basic` | 0.01 | **0.05** | 0.025 s | 2 帧×89 | 30 /s(钳制生效)|
| `wand_basic` | 0.1soft | 0.1 | 0.05 s | 3 帧×59 | 20 /s(对照,未受影响)|
| `circuit_fork` | 0.05 | 0.05 | 0.03 s | 2 帧×89 | 30 /s(最慢杖吃不满硬顶,见 spec §2.2b 遗留)|
**未在代码里加下限钳制** —— 下限已由 `attributes.json` + `AttributeFormula``maxf(hard, ...)` 强制,在 `player_manager` 再加一个会形成重复权威,设计师调 `hard` 时立刻脱同步,违反「纯数据驱动,代码不含硬编码副本或回退」。
**③ 顺带收两条 Task 3 复审的 Minor**(提交 `9f71108`): **③ 顺带收两条 Task 3 复审的 Minor**(提交 `9f71108`):
- `player_manager.gd` 守卫注释里的 `player_stats.gd:173/191` 行号引用会腐烂,改为函数名 `PlayerStats._load_attr_definitions` / `PlayerStats.add_modifier` - `player_manager.gd` 守卫注释里的 `player_stats.gd:173/191` 行号引用会腐烂,改为函数名 `PlayerStats._load_attr_definitions` / `PlayerStats.add_modifier`
@@ -70,7 +70,7 @@
| :-- | --: | --: | --: | :-- | :-- | | :-- | --: | --: | --: | :-- | :-- |
| `cpu_limit` | **0** | 20 | 50 | `add_int` | 修死数据;生效 MAX_OPS 用 `玩家 + 法杖` | | `cpu_limit` | **0** | 20 | 50 | `add_int` | 修死数据;生效 MAX_OPS 用 `玩家 + 法杖` |
| `move_speed` | **200** | 600 | 800 | `hybrid` | 由 `const` 转为属性 | | `move_speed` | **200** | 600 | 800 | `hybrid` | 由 `const` 转为属性 |
| `cast_delay_mod` | 1.0 | 0.1 | 0.01 | `inverse` | 新增,乘到 `core.cast_interval` | | `cast_delay_mod` | 1.0 | 0.1 | **0.05** | `inverse` | 新增,在**开火时**乘到 `core.cast_interval``hard` 原定 0.012026-07-31 据实测上调至 0.05,见 §2.2b |
| `hp_max` | 100 | 2000 | — | `hybrid` | 已可用,迁入框架 | | `hp_max` | 100 | 2000 | — | `hybrid` | 已可用,迁入框架 |
**`cpu_limit` 玩家基准取 0**(权威表写 5):因生效值 = 玩家 + 法杖,而法杖已提供 3–8。取 0 使小木法杖仍为 5 → MAX_OPS 200,**与当前行为完全一致,零平衡改动**;取 5 会让所有法杖的执行预算翻倍。语义上玩家属性是「全局加成」(权威 §1.1 开头:「这些属性是全局的,会修正所有法杖的输出」),基准为 0 正是纯加成语义。 **`cpu_limit` 玩家基准取 0**(权威表写 5):因生效值 = 玩家 + 法杖,而法杖已提供 3–8。取 0 使小木法杖仍为 5 → MAX_OPS 200,**与当前行为完全一致,零平衡改动**;取 5 会让所有法杖的执行预算翻倍。语义上玩家属性是「全局加成」(权威 §1.1 开头:「这些属性是全局的,会修正所有法杖的输出」),基准为 0 正是纯加成语义。
@@ -91,6 +91,93 @@ PlayerStats.add_modifier("cpu_limit", "flat", float(core.cpu_limit), "core")
**`mana_max` 本期不迁入**:权威 §1.1 写明「法杖也有自己的上限,取 Min 值」,而现状是核心值直接覆盖玩家值。那个 Min 语义需要独立的设计判断(取 Min 后玩家升级蓝上限在低上限法杖下完全无效,这是否是设计意图?),不应混进属性框架这一期顺手改。本期保持原样,记录为已知遗留。 **`mana_max` 本期不迁入**:权威 §1.1 写明「法杖也有自己的上限,取 Min 值」,而现状是核心值直接覆盖玩家值。那个 Min 语义需要独立的设计判断(取 Min 后玩家升级蓝上限在低上限法杖下完全无效,这是否是设计意图?),不应混进属性框架这一期顺手改。本期保持原样,记录为已知遗留。
### 2.2b `cast_delay_mod` 的饱和点 —— 实测数据与 `hard` 上调(2026-07-31 追记)
> 本节记录的是**运行时实测**,不是推导。数据来自验收期(Task 5 Step 7b②)向 `get_tree().root`
> 挂载的临时 `_physics_process` 扫描节点:16 个相位 × 180 物理帧,逐相位切换法杖与
> `cast_delay_mod`,记录**相邻两次施法的帧距分布**。
#### 现象
`player_manager._handle_auto_cast``if` 而非 `while`
```gdscript
_cast_timer -= delta
if _cast_timer <= 0.0:
_cast_timer = _cast_interval * PlayerStats.cast_delay_mod # 赋值,非 -= 或 +=
SpellEvaluator.execute_compiled(...)
```
因此**每物理帧至多施法一次**,60 Hz 物理步长下硬顶 60 次/秒。且 `_cast_timer` 是**赋值**而非累积扣减,
亚帧余量每周期被丢弃 —— 实际射速按**整帧量化**。
#### 实测表
每个相位的帧距分布都是**单值**(如「1 帧×179」「2 帧×89」),这本身就是量化的直接证据。
| 法杖 | `cast_delay_mod` | 生效间隔 T | 实测帧距 | 实际速率 |
| :-- | --: | --: | --: | --: |
| `wand_basic`(基准 0.5 s | 1.0 | 0.5 s | 31 帧 | 1.94 /s |
| | 0.5 | 0.25 s | 16 帧 | 3.75 /s |
| | **0.1soft** | 0.05 s | 3 帧 | **20 /s** |
| | 0.07 | 0.035 s | 3 帧 | 20 /s |
| | 0.06 | 0.03 s | 2 帧 | 30 /s |
| | **0.05(现 hard** | 0.025 s | 2 帧 | **30 /s** |
| | 0.04 | 0.02 s | 2 帧 | 30 /s |
| | 0.034 | 0.017 s | 2 帧 | 30 /s |
| | **0.033** | 0.0165 s | 1 帧 | **60 /s ← 饱和点** |
| | 0.02 | 0.01 s | 1 帧 | 60 /s |
| | 0.01(原 hard | 0.005 s | 1 帧 | 60 /s |
| `wand_fast`(基准 0.25 s | 1.0 | 0.25 s | 16 帧 | 3.75 /s |
| | **0.1soft** | 0.025 s | 2 帧 | **30 /s** |
| | 0.07 | 0.0175 s | 2 帧 | 30 /s |
| | 0.0667 | 0.016675 s | 2 帧 | 30 /s |
| | **0.05(现 hard** | 0.0125 s | 1 帧 | **60 /s ← 恰好饱和** |
| | 0.01(原 hard | 0.0025 s | 1 帧 | 60 /s |
#### 四条结论
1. **饱和点 = `(1/60) / 法杖基准间隔`**
| 法杖 | 基准间隔 | 饱和点 | 来源 |
| :-- | --: | --: | :-- |
| `wand_basic` | 0.5 s | ≈ **0.0333** | 实测(0.034 仍 2 帧、0.033 已 1 帧)|
| `wand_fast` | 0.25 s | ≈ **0.0667** | 实测(0.0667 仍 2 帧、0.05 已 1 帧)|
| `circuit_fork` | 0.6 s | ≈ 0.0278 | **公式推导,未实测** |
2. **soft→hard 之间的可达档位是台阶而非曲线**`wand_basic` 只有 20 / 30 / 60 次每秒三档,
`wand_fast` 只有 30 / 60 两档。中间值买不到。
3. **周期恒为 `ceil(T × 60)` 帧,且 T 恰为整帧倍数时会多花一帧**`T = 0.5`**31** 帧而非 30
`T = 0.25`**16** 帧而非 15):`_cast_timer` 是赋值,`0.5 - 30 × delta` 的浮点余量为正,
要到第 31 帧才跨过 0。故**实际射速恒 ≤ 名义值,永不超发** —— 这是好事,但意味着名义
「2 次/秒」实际是 1.94 次/秒。
4. 综上,原 `hard: 0.01` 在两把主力杖上都**深深落在饱和区内**:`wand_basic` 白费 3.3 倍区间、
`wand_fast` 白费 6.7 倍。玩家在货架 C 花钱把 `cast_delay_mod` 从 0.033 买到 0.01**射速一点没变**。
#### 决定(用户 2026-07-31
`hard: 0.01 → 0.05`。取 0.05 的依据正是上表:`wand_fast` 恰在 0.05 饱和,`wand_basic` 在 0.0333 饱和,
故 0.05 使**两把主力杖都不再有「买了没用」的无效区间**,让 JSON 承诺的是真能交付的东西。
**明确不做**:不在代码里加下限钳制。下限已由 `attributes.json` + `AttributeFormula`
`maxf(hard, ...)` 强制;在 `player_manager` 再加一个会形成**重复权威**,设计师调 `hard` 时立刻脱同步,
违反 CLAUDE.md「纯数据驱动,代码不含硬编码副本或回退」。
#### 改动后的边界复测(`hard = 0.05` 落地后,同一套扫描)
| 法杖 | 请求 `mod` | 生效 `mod` | 生效间隔 T | 实测帧距 | 实际速率 | 判读 |
| :-- | --: | --: | --: | --: | --: | :-- |
| `wand_fast` | 0.05 | 0.05 | 0.0125 s | **1 帧×179** | 60 /s | ✅ 恰好饱和,硬上限一格不浪费 |
| `wand_fast` | 0.01 | **0.05** | 0.0125 s | 1 帧×179 | 60 /s | ✅ `maxf(hard, …)` 生效,越界请求被钳回 |
| `wand_basic` | 0.05 | 0.05 | 0.025 s | **2 帧×89** | 30 /s | ✅ |
| `wand_basic` | 0.01 | **0.05** | 0.025 s | 2 帧×89 | 30 /s | ✅ 钳制生效 |
| `wand_basic` | 0.1soft | 0.1 | 0.05 s | 3 帧×59 | 20 /s | ✅ 对照,soft 段未受影响 |
| `circuit_fork` | 0.05 | 0.05 | 0.03 s | **2 帧×89** | 30 /s | ✅ 见下方遗留 |
> **遗留(实测)**`circuit_fork`0.6 s)在新 `hard` 0.05 处实测为 **2 帧/次(30 /s** ——
> 它的饱和点 0.0278(公式推导)低于 0.05,故**没有**无效区间,但也吃不满 60 /s。这是
> 「用最快的杖定 `hard`」的必然结果,属可接受的取舍:**无效区间清零优先于人人吃满硬顶**
> (无效区间是"花了钱没效果",吃不满硬顶只是"上限没那么高")。若日后引入基准间隔更长的
> 法杖,此结论不变。
### 2.3 公式模块 —— `AttributeFormula` ### 2.3 公式模块 —— `AttributeFormula`
**所有加成公式集中在一个专门模块**,与状态持有分离。 **所有加成公式集中在一个专门模块**,与状态持有分离。