Files
spellforge/docs_dev/specs/2026-07-31-player-attributes-design.md
T
joywayerandClaude Opus 5 d3c5edb1cb docs(spec): E3-① 玩家属性系统设计——公式模块 + 修三条死数据 + 范围裁剪
范围按权威文档裁剪:路线图原写的属性名(暴击/急速/范围/吸血/减伤)是拟稿推测,
不存在于任何权威文档;numerical_design §1.1 定义的 11 个属性才是权威。
本期只做「框架 + 修既有死线」,排除 attunement×4(新玩法维度)、luck(暴击链是死的)、
recharge_speed_mod(充能系统压根不存在)。

加成不做简单叠加:每属性在 JSON 声明 combine 公式——hybrid 连乘、
inverse 反向且下限钳制、add_int 拒绝 pct。公式集中在独立的 AttributeFormula
纯静态模块,无状态零依赖,故公式正确性可脱离游戏进程单元断言。

自审修正:法杖 cpu_limit 改为以加成来源接入而非调用点相加——否则 hard 上限
只钳制玩家那一份,法杖份额加在钳制之后可使总值越界;顺带使加成层从第一天
就有真实消费者。

move_speed 取既成事实 200 并订正权威表的 300:该值从未被任何代码读取过,
而 200 自 S0 沿用并已围绕它调校 20 波内容——与归航定价那次相反,
「以权威为准」要看那条权威有没有被实践检验过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 10:32:41 +08:00

230 lines
17 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.01 | `inverse` | 新增,乘到 `core.cast_interval` |
| `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.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 版本号提升。