_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>
317 lines
22 KiB
Markdown
317 lines
22 KiB
Markdown
# 玩家属性系统(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.01,2026-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.1(soft)** | 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.1(soft)** | 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.1(soft) | 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`
|
||
|
||
**所有加成公式集中在一个专门模块**,与状态持有分离。
|
||
|
||
`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 resistance(E1-① 已上线),玩家侧做成镜像是自洽的,但那是**扩充权威属性表**的设计决策,需走正常立项流程。
|
||
|
||
### 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 版本号提升。
|