Files
spellforge/docs_dev/specs/2026-07-31-player-attributes-design.md
T
joywayerandClaude Opus 5 84c10725e2 docs: 订正 wand_fast 饱和点 0.05→0.0667;roadmap 关闭已决定项并澄清移速表述
【订正】wand_fast(0.25s) 的饱和点按公式 (1/60)/0.25 = 0.0667,不是 0.05。
numerical_design:21 的标注与它自己上一句的公式直接矛盾,spec/plan 另有三处同源
错误标注——均派生自一句口头转述,实测表里记的 0.0667 一直是对的。

结论也随之改对,不只是改数字:hard=0.05 使 wand_basic(饱和 0.0333) 与
circuit_fork(0.0278) 无无效区间,但 wand_fast 仍剩 [0.05, 0.0667) 这段买不到东西
(较原 hard:0.01 的 6.7 倍缩至 1.33 倍)。仍取 0.05 的理由:饱和点 = (1/60)/法杖
基准间隔,随杖而异,不存在对所有杖都最优的单一 hard——取 0.0667 让 wand_fast 干净
会使另两把杖够不到自己的饱和点,反而制造新的不可达区间。hard 数值不变。

顺带说明实测表里 wand_fast@0.0667 读到 2 帧的原因:采样点比真实边界 1/15 高 3.3e-5
(T 上高 8.3e-6 s),再叠加整帧倍数多花一帧的浮点余量效应,故实际 1 帧上界略低于
0.0667。数据没错,是标签错了。

【roadmap】E3-① 下的 cast_delay_mod 条目仍记为「待人定」并指向两个选项,而该决定
已在 ba7c885 关闭——roadmap 是查「E3 还剩什么」的索引,留着会把已关闭的决定重新打开。
改为已处理,并注明彻底消除需让 hard 逐杖化(挪进 cores.json),属独立立项。

【roadmap】「实测 200→300 px/s」读起来像发版移速由 200 改成 300,与同批改动里
numerical_design 的订正(发版是 200,300 是从未被读过的纸面值)直接冲突。改写为
「发版基准仍是 200;实测手段是临时挂 +50% 加成使生效值变 300」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:36:53 +08:00

349 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 玩家属性系统(Player Attributes)设计
> **日期**2026-07-31
> **Epic**E3 经济与 build · 子计划 ①(缺失功能路线图 `docs_dev/plans/2026-07-23-missing-features-roadmap.md`
> **优先级**P0 · 工作量 M
> **目标**:建立数据驱动的玩家属性框架(含独立的加成公式模块),并接线三条既有死数据;为子计划 ②「货架 C(属性购买)」提供接入点。
---
## 0. 范围界定(重要)
**路线图对本子计划的描述是错的,本 spec 以权威文档为准。**
路线图写「定义 4/5 缺失 player stats(如暴击/急速/范围/吸血/减伤)」—— 这些属性名是拟稿时的推测,不存在于任何权威文档。实际的权威属性表是 `docs/design/numerical_design.md` §1.1,内容完全不同(11 个属性:`hp_max` / `mana_max` / `move_speed` / `cast_delay_mod` / `recharge_speed_mod` / `luck` / `cpu_limit` / `attunement_×4`)。
11 个属性 + 加成层 + 数据文件 + 设计器页,对单个 spec 过大。**本期只做「属性框架 + 修复既有死线」**,即那些「消费方已存在、只是没接上」的属性。明确排除:
- `attunement_fire/ice/lightning/poison`(4 个元素精通)—— 新增玩法维度,需接入元素伤害管线,独立立项。
- `luck` —— 依赖暴击链,而暴击链目前是死的(见 §1.3),独立立项。
- `recharge_speed_mod` —— 代码中**不存在**充能系统(全项目 grep `recharge` 零命中),属新增机制而非断线,独立立项。
- `mana_max` 迁入框架 —— 见 §2.2 的理由。
## 1. 背景与现状(代码复核 2026-07-31
### 1.1 三条死数据
- **`cpu_limit` 双份死数据**`PlayerStats.cpu_limit``player_stats.gd:21`,进存档)与 `CoreDefinition.cpu_limit``core_definition.gd:16``cores.json`,逐法杖 3/5/6/8,设计器可改)**两者都不被读取**。`spell_evaluator.gd:332` 写死 `var max_ops: int = MAX_OPS_PER_CPU * 5`。权威文档 §1.1 自己已标注此 bug(「代码硬编码 MAX_OPS = 40×5 = 200,从不读 cpu_limit」)。后果:六个法杖各自配置过的运算力完全无效,法杖间少了一个区分维度,且违反「纯数据驱动」。
- **`move_speed` 不是属性**`player_manager.gd:7``const MOVE_SPEED: float = 200.0`,硬编码常量。
- **`cast_delay_mod` 不存在**`player_manager.gd:88` 直接取 `core.cast_interval`,无任何全局修正系数。
### 1.2 孤儿字段
`PlayerStats.armor`:22)与 `PlayerStats.resistance`(:23**零消费方**(全项目 grep `PlayerStats.armor` / `PlayerStats.resistance` 无命中),且**不在权威属性表 §1.1 内**。`take_damage`(:65)只乘难度系数,两者从未参与伤害计算。
它们不是「断线」而是**实现先于设计的残留**:从来没有被设计过。
### 1.3 暴击链是死的(影响 `luck` 的排除决定)
`CastStats.crit_chance``cast_stats.gd:13`)只在 CIRCUIT 分支快照中被读写,从未被掷判;`bullet_manager.gd:81``DamageContextPool.acquire(b_dmg, b_mult, b_type, b_own, false, 0.0)``is_crit` **硬编码为 `false`**。故权威 §1.1 中「`luck` 影响暴击率」这条设计,落地前必须先打通暴击链 —— 属独立工作。
### 1.4 可复用的既有模式
`PlayerStats.mana_leech` 是本项目已验证的「多来源属性重算」模式:`combat_manager._rebuild_wand` 在换杖/换牌时按 `core.base_mana_leech + Σ(deck node meta.mana_leech)` 重算,读取端是裸字段。本设计的加成层复刻此模式。
## 2. 设计
### 2.1 加成模型 —— 公式而非叠加
**核心约束(用户 2026-07-31 指定)**:任何加成都必须有明确的合成公式,不做简单叠加。这与权威 §0 的核心公式 `构建强度 = (基础数值 × 修正系数) ^ 逻辑复杂度` 一致 —— 修正是**乘算系数**。
一条加成是 `{attr_id: String, mode: String, value: float, source: String}``mode` 仅两种:`flat`(平坦加减)、`pct`(百分比)。
每个属性在 `attributes.json` 声明自己的 `combine`
| `combine` | 公式 | 用于 |
| :-- | :-- | :-- |
| `hybrid`(默认) | `(base + Σflat) × Π(1 + pct)` | `move_speed``hp_max` |
| `inverse` | `(base + Σflat) × Π(1 pct)`**下限**钳制到 `hard` | `cast_delay_mod`(越低越快) |
| `add_int` | `base + Σflat`,取整;**拒绝 `pct`** | `cpu_limit`(离散指令预算) |
**乘算用连乘 `Π` 而非线性求和 `Σ`,这是刻意的**:三个「+20% 移速」在连乘下是 ×1.728,线性求和则是 ×1.6。连乘永远逼近而不会突然爆炸,也避免「+50% × 2 = +100%」这类线性叠加导致的后期失控。
`inverse``pct``(1 pct)`:「施法延迟 −20%」得 ×0.8,符合直觉;连乘保证永远碰不到 0,再由 `hard` 兜底。
`add_int` **拒绝** `pct``push_error`:对离散指令预算做乘算无意义(「1.5 个 CPU」不存在),静默取整会掩盖配置错误。
### 2.2 本期纳入的属性
| 属性 | base | soft | hard | combine | 处理 |
| :-- | --: | --: | --: | :-- | :-- |
| `cpu_limit` | **0** | 20 | 50 | `add_int` | 修死数据;生效 MAX_OPS 用 `玩家 + 法杖` |
| `move_speed` | **200** | 600 | 800 | `hybrid` | 由 `const` 转为属性 |
| `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` | 已可用,迁入框架 |
**`cpu_limit` 玩家基准取 0**(权威表写 5):因生效值 = 玩家 + 法杖,而法杖已提供 3–8。取 0 使小木法杖仍为 5 → MAX_OPS 200,**与当前行为完全一致,零平衡改动**;取 5 会让所有法杖的执行预算翻倍。语义上玩家属性是「全局加成」(权威 §1.1 开头:「这些属性是全局的,会修正所有法杖的输出」),基准为 0 正是纯加成语义。
**法杖的 `cpu_limit` 作为一条加成来源接入,而非在调用点相加**(自审修正):
```gdscript
# combat_manager._rebuild_wand(与既有 mana_leech 重算同处、同时机)
PlayerStats.remove_modifiers_from("core")
PlayerStats.add_modifier("cpu_limit", "flat", float(core.cpu_limit), "core")
```
理由是钳制位置。若在 `spell_evaluator` 调用点写 `PlayerStats.cpu_limit + core.cpu_limit`,则 `hard`(50) 只钳制到玩家那一份,法杖的 3–8 加在钳制**之后**,总值可达 58 —— 硬上限失效。走加成来源则 `hard` 天然作用于生效总值,`spell_evaluator` 只需读 `PlayerStats.cpu_limit` 一个数。
这还有两个附带好处:① `_modifiers` 本期**不再是空的**,加成系统从第一天起就有真实消费者,而非「有机制无消费者」;② 验收第 11 条(`remove_modifiers_from`)得以对真实来源而非合成数据验证。换杖时机与 `mana_leech` 完全一致,无需新增钩子。
**`move_speed` 基准取 200(权威表写 300),并订正权威表**:权威的 300 **从未被任何代码读取过**,是纸面孤值;而 200 自 S0 沿用至今,20 波内容、Boss 弹幕密度、无敌帧 0.5 s 均按此值调校。此处既成事实的信息量大于纸面设计 —— 改成 300 等于用未经验证的数字推翻一整轮已调校的内容。
> **与归航那次的对照(重要,避免误用「以权威为准」)**`numerical_design.md` 的 Mana 列与**每一个**已实现法术吻合,证明该列是活的、被遵守的,故归航定价越权是真错误。而 `move_speed` 300 从未被读取,是同一文档中未被实践检验的部分。「以权威为准」不是无条件的 —— 要看那条权威有没有被实践检验过。
**`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.0667,见下)** |
| | 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**= 1/15| 实测夹在 (0.05, 0.0667] 内:0.0667 仍 2 帧、0.05 已 1 帧 |
| `circuit_fork` | 0.6 s | ≈ 0.0278 | **公式推导,未实测** |
> **关于 `wand_fast @ 0.0667` 读到 2 帧**:数据没错,是采样点的问题。真实边界是 `1/15 = 0.06666…`
> 我取的 `0.0667` 比它高 `3.3e-5`T 上高 `8.3e-6 s`),故落在 2 帧那侧。再叠加结论 3 的浮点余量效应
> —— T 恰为整帧倍数时反而多花一帧 —— **实际的 1 帧上界略低于 0.0667**。故 `wand_fast` 的饱和点
> 应读作「0.0667 稍下方」,本文一律以公式值 0.0667 记,误差方向是保守的(真实无效区间只会**更大**一点)。
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`
**关键前提:饱和点 = `(1/60) / 法杖基准间隔`,随杖而异,因此不存在对所有杖都最优的单一 `hard`。**
`hard` 是**全局**的一个数,而每把杖的饱和点各不相同(0.0278 / 0.0333 / 0.0667),任何取值都只能覆盖一部分:
| `hard` 取值 | `circuit_fork`(0.0278) | `wand_basic`(0.0333) | `wand_fast`(0.0667) |
| :-- | :-- | :-- | :-- |
| 0.01(原值) | 无效区间 0.0278→0.01 | 无效区间 0.0333→0.01 | 无效区间 0.0667→0.016.7 倍)|
| **0.05(已采用)** | ✅ 无无效区间 | ✅ 无无效区间 | ⚠️ 仍剩 `[0.05, 0.0667)` |
| 0.0667 | ✅ | ✅ | ✅ 无无效区间 |
—— 但 `0.0667` 会让 `wand_basic`(饱和 0.0333)与 `circuit_fork`(0.0278)**够不到自己的饱和点**,
即它们的最高射速被硬上限提前截断,**制造出新的"不可达区间"**(想更快但买不到)。
**故 0.05 是折中**:让**多数杖**`wand_basic` / `circuit_fork`,以及所有基准间隔 ≥ 0.333 s 的杖)
无无效区间,只在最快的 `wand_fast` 上留 `[0.05, 0.0667)` 这一小段。相对原 `0.01`
(两把主力杖都深陷饱和区)是大幅改进,但**不是"两把主力杖都不再有无效区间"** ——
`wand_fast` 那段仍在,只是从 6.7 倍缩到 1.33 倍。
> **📌 订正记录(2026-07-31**:本节初稿曾写「`wand_fast` 恰好在 0.05 饱和 / 两把主力杖都无无效区间」,
> **算错了** —— 按同一节自己给出的公式 `(1/60)/0.25 = 0.0667`,不是 0.05。实测数据表(§2.2b 上方)
> 记的 0.0667 一直是对的,错的只是从那句口头转述派生出来的几处标注。已连同
> `numerical_design.md` §1.1、roadmap E3-①、plan Step 7b② 一并订正。
> **教训与本期其余三条同源**:结论若与自己上一句的公式矛盾,是最容易被略过的一类错 ——
> 因为读者会假定作者算过。
**明确不做**:不在代码里加下限钳制。下限已由 `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 | ⚠️ 已达 60 /s,但饱和点是 0.0667 —— `[0.05, 0.0667)` 这段买了没用 |
| `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`wand_basic`
> 同理(0.0333 < 0.0530 /s)。反过来 `wand_fast`0.0667 > 0.05)能吃满 60 /s,代价是
> `[0.05, 0.0667)` 那段无效区间。
>
> **这正是"单一全局 `hard` 撞上逐杖饱和点"的必然取舍**:`hard` 偏小则慢杖有无效区间,偏大则
> 快杖被截断。0.05 选择了**让无效区间只留在一把杖上**,因为无效区间是"花了钱没效果"(欺骗),
> 而吃不满硬顶只是"上限没那么高"(诚实)。若日后要彻底消除,正确做法是让 `hard` 逐杖化
> (即把它变成 `cores.json` 的一个字段),而不是继续挪这个全局数 —— 那属于新的设计立项。
### 2.3 公式模块 —— `AttributeFormula`
**所有加成公式集中在一个专门模块**,与状态持有分离。
`scripts/domain/attribute_formula.gd`
```gdscript
## AttributeFormula — 属性合成公式的唯一实现处
## 纯静态函数,无状态、不依赖 PlayerStats / EventBus / 场景树
class_name AttributeFormula
extends RefCounted
enum Combine { HYBRID, INVERSE, ADD_INT }
## 唯一的公式入口:base + 加成列表 + 属性定义 → 生效值
static func compute(base: float, mods: Array, attr_def: Dictionary) -> float
## combine 字符串(JSON)→ 枚举;未知值 push_error 并回退 HYBRID
static func combine_from_string(s: String) -> Combine
```
设计要点:
1. **纯函数、零依赖** —— 因此**不需要启动游戏就能测**。公式正确性(连乘 vs 线性、`inverse` 方向、`add_int` 拒绝 `pct``hard` 两个方向的钳制)全部是 `execute_editor_script` 里的静态函数断言,比在运行中的战斗场景构造玩家状态更快、更确定,且改公式时能立刻回归。
2. **公式的唯一实现处** —— 改公式只需读这一个文件,不必翻还管着 HP/金币/经验/魔力/无敌帧的 `PlayerStats`
3. `class_name` 注册的 `RefCounted` 值对象,与 `cast_stats.gd` 同类,放同一层级风格的 `scripts/domain/`
> **实现注意**`class_name` 脚本会触发 `validate_script` 的既有假阴性(报 `hides a global script class`)—— 工具问题非代码问题。验证用既有绕法:`GDScript.new()` + 从磁盘读 `source_code` + `reload()`。
### 2.4 `PlayerStats` 结构
`PlayerStats` 瘦回单一职责 —— **只负责加载数据、持有加成列表、把公式结果写进类型化字段**,一个公式都不含。
```gdscript
# 生效值:静态类型裸字段,读取端零开销(move_speed 每物理帧被 player_manager 读)
var move_speed: float = 200.0
var cast_delay_mod: float = 1.0
var cpu_limit: int = 0
var _attr_def: Dictionary = {} # attributes.json 全量定义,只读
var _modifiers: Array[Dictionary] = [] # 加成来源;本期恒空
func add_modifier(attr_id: String, mode: String, value: float, source: String) -> void
func remove_modifiers_from(source: String) -> void
func _recompute_attrs() -> void # 逐属性调 AttributeFormula.compute,写回裸字段
```
`compute` 统一返回 `float``add_int` 属性在写回时由 `PlayerStats``int()` 转换(`cpu_limit = int(AttributeFormula.compute(...))`)。公式模块不感知目标字段的静态类型,保持纯粹。
**读取端永远是裸字段访问** —— 零开销、静态类型,符合 CLAUDE.md 的热路径纪律(`move_speed` 每物理帧被读,字典查找不可接受)。写入端集中在 `_recompute_attrs()`
`remove_modifiers_from` 本期就加:子计划 ④「出售退款」必然需要按来源撤销加成,届时不加就要改签名或加并行结构。
> **本期已有的真实加成来源**:法杖的 `cpu_limit` 以 `source = "core"` 接入(§2.2),故 `_modifiers` 从第一天起就非空,加成层不是「有机制无消费者」。
>
> **本期确实无消费方的部分**:`soft` 字段(钳制只用 `hard`)。保留理由是它属于权威表已写死的设计数据,不存反而丢失信息,且子计划 ②「货架 C」要靠它区分「常规来源可达」与「需特殊来源突破」。`pct` 模式本期也无来源使用(`"core"` 用的是 `flat`),但公式与其单元断言一并落地 —— 否则货架 C 落地时才第一次验证乘算,风险后置。
### 2.5 三处接线
| 位置 | 改动 |
| :-- | :-- |
| `spell_evaluator.gd:332` | `MAX_OPS_PER_CPU * 5``MAX_OPS_PER_CPU * PlayerStats.cpu_limit`(法杖份额已由 §2.2 的 `"core"` 加成来源计入) |
| `combat_manager._rebuild_wand` | 换杖/换牌时 `remove_modifiers_from("core")` + `add_modifier("cpu_limit", "flat", core.cpu_limit, "core")`,与既有 `mana_leech` 重算同处 |
| `player_manager.gd:7,45` | 删 `const MOVE_SPEED`,改读 `PlayerStats.move_speed` |
| `player_manager.gd:88` | `_cast_interval = core.cast_interval``× PlayerStats.cast_delay_mod` |
### 2.6 删除孤儿字段
`PlayerStats.armor`:22)与 `PlayerStats.resistance`:23)。
理由:零消费方、不在权威属性表内 —— 留着会误导(下一个人看到 `var resistance: float = 0.0 # 0.0~1.0` 会以为玩家抗性已设计好只差接线)。
> **真要做玩家侧抗性/护甲时怎么立项**:它是**货架 C 属性词条**的好候选(子计划 ②)—— 届时有明确消费场景(花钱买减伤),连同权威 §1.1 表一起补充定义,而非像现在这样悬空。敌人侧已有 armor`enemy_manager`)与 per-element resistanceE1-① 已上线),玩家侧做成镜像是自洽的,但那是**扩充权威属性表**的设计决策,需走正常立项流程。
### 2.7 数据与设计器
`data/attributes.json`
```json
{
"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" },
"cast_delay_mod": { "display_name": "施法延迟 cast_delay_mod","base": 1.0, "soft": 0.1, "hard": 0.01, "combine": "inverse" },
"hp_max": { "display_name": "最大生命 hp_max", "base": 100.0, "soft": 2000.0, "hard": 0.0, "combine": "hybrid" }
}
```
`hard: 0.0` 表示无硬上限(`hybrid` 下 0 视为不钳制;`inverse``hard` 是下限,不适用此约定 —— 见 §2.1)。
设计器新增「属性」页(`addons/game_designer/attribute_tab.gd`):每属性一行,`base`/`soft`/`hard``UI.spin``combine``UI.opt`(存 key 不存显示串),标签「中文 English」双语序,经 `designer_ui.gd` 工厂创建。按 CLAUDE.md「新增分页 3 步」注册。
## 3. 影响文件
| 文件 | 改动 |
| :-- | :-- |
| `scripts/domain/attribute_formula.gd` | **新增** —— 公式模块 |
| `scripts/autoloads/player_stats.gd` | 属性字段 + 加成列表 + `_recompute_attrs`;删 `armor`/`resistance` |
| `scripts/domain/spell_system/spell_evaluator.gd` | `:332` MAX_OPS 接线 |
| `scripts/domain/player_manager.gd` | 删 `const MOVE_SPEED`;移速与施法间隔接线 |
| `data/attributes.json` | **新增** |
| `addons/game_designer/attribute_tab.gd` | **新增** 设计器页 |
| `addons/game_designer/designer_panel.gd` | 注册新分页 |
| `docs/design/numerical_design.md` | §1.1 `move_speed` 基准 300 → 200 并注明理由 |
## 4. 验收标准
**公式类(纯函数单元断言,无需启动游戏)**
1. `hybrid` 连乘:三条 `+20% pct`×1.728(**非** ×1.6,证明是连乘而非线性求和)。
2. `hybrid` 混合:`base 200 + flat 50``×(1+0.2)` → 300。
3. `inverse` 方向:`base 1.0` + 三条 `20% pct` → 0.512;再多条也不小于 `hard`0.01)。
4. `add_int` 拒绝 `pct`:传入 `pct` 加成时 `push_error` 且该条被忽略,`flat` 仍正确累加。
5. `hard` 两个方向:`hybrid` 上限钳制、`inverse` 下限钳制;`hard: 0.0``hybrid` 下表示不钳制。
6. 边界:空加成列表 → 返回 base;未知 `combine` 字符串 → `push_error` 并回退 `hybrid`
**接线类(运行时实测)**
7. `cores.json``cpu_limit` 生效:矩阵板(8) → MAX_OPS 320、高速法杖(3) → 120(改前恒为 200)。
8. **现有平衡零改动**:小木法杖(5) 仍为 MAX_OPS 200。
9. **换杖时加成来源正确轮换**:小木(5) → 矩阵板(8) 后 `PlayerStats.cpu_limit` 为 8 而非 13(证明 `remove_modifiers_from("core")` 先于 `add_modifier` 生效,旧法杖份额未累积)。
10. `move_speed` 数据驱动:改 `attributes.json` 后玩家实际移速跟随变化。
11. `cast_delay_mod = 0.5` → 实际 `_cast_interval` 减半。
12. `remove_modifiers_from(source)` 精确撤销该来源的全部加成且重算正确 —— 混入一条他源加成,撤销 `"core"` 后他源仍在。
**清理与规范**
12. `armor` / `resistance` 全项目零残留(含存档 `to_dict`/`from_dict`)。
13. 设计器「属性」页对真实 `data/attributes.json` 回读往返一致;`combine` 下拉存 key 不存显示串。
14. 解析通过(`class_name` 脚本用 `GDScript.reload()` 绕法验证);纯数据驱动,文件缺失时 `push_error` 明确报错不静默回退。
## 5. 非目标(YAGNI
- `attunement_×4` / `luck` / `recharge_speed_mod` —— 见 §0,各自独立立项。
- `mana_max` 的「与法杖取 Min」语义 —— 见 §2.2,独立设计判断。
- 打通暴击链 —— 见 §1.3`luck` 的前置。
- 货架 C(属性购买 UI 与经济)—— 子计划 ②;本期只提供 `add_modifier` 接入点。
- 升级时自动加属性 —— 本期不引入任何加成来源。
- 玩家侧 armor/resistance —— 见 §2.6,删除并记录立项路径。
- 存档迁移 —— `armor`/`resistance` 从未被写入过有意义的值,旧存档读取时忽略即可,不需要 schema 版本号提升。