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

17 KiB
Raw Blame History

玩家属性系统(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_limitplayer_stats.gd:21,进存档)与 CoreDefinition.cpu_limitcore_definition.gd:16cores.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:7const 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_chancecast_stats.gd:13)只在 CIRCUIT 分支快照中被读写,从未被掷判;bullet_manager.gd:81DamageContextPool.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_speedhp_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%」这类线性叠加导致的后期失控。

inversepct(1 pct):「施法延迟 −20%」得 ×0.8,符合直觉;连乘保证永远碰不到 0,再由 hard 兜底。

add_int 拒绝 pctpush_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 作为一条加成来源接入,而非在调用点相加(自审修正):

# 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

## 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 拒绝 pcthard 两个方向的钳制)全部是 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 瘦回单一职责 —— 只负责加载数据、持有加成列表、把公式结果写进类型化字段,一个公式都不含。

# 生效值:静态类型裸字段,读取端零开销(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 统一返回 floatadd_int 属性在写回时由 PlayerStatsint() 转换(cpu_limit = int(AttributeFormula.compute(...)))。公式模块不感知目标字段的静态类型,保持纯粹。

读取端永远是裸字段访问 —— 零开销、静态类型,符合 CLAUDE.md 的热路径纪律(move_speed 每物理帧被读,字典查找不可接受)。写入端集中在 _recompute_attrs()

remove_modifiers_from 本期就加:子计划 ④「出售退款」必然需要按来源撤销加成,届时不加就要改签名或加并行结构。

本期已有的真实加成来源:法杖的 cpu_limitsource = "core" 接入(§2.2),故 _modifiers 从第一天起就非空,加成层不是「有机制无消费者」。

本期确实无消费方的部分soft 字段(钳制只用 hard)。保留理由是它属于权威表已写死的设计数据,不存反而丢失信息,且子计划 ②「货架 C」要靠它区分「常规来源可达」与「需特殊来源突破」。pct 模式本期也无来源使用("core" 用的是 flat),但公式与其单元断言一并落地 —— 否则货架 C 落地时才第一次验证乘算,风险后置。

2.5 三处接线

位置 改动
spell_evaluator.gd:332 MAX_OPS_PER_CPU * 5MAX_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

{
  "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 视为不钳制;inversehard 是下限,不适用此约定 —— 见 §2.1)。

设计器新增「属性」页(addons/game_designer/attribute_tab.gd):每属性一行,base/soft/hardUI.spincombineUI.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;再多条也不小于 hard0.01)。
  4. add_int 拒绝 pct:传入 pct 加成时 push_error 且该条被忽略,flat 仍正确累加。
  5. hard 两个方向:hybrid 上限钳制、inverse 下限钳制;hard: 0.0hybrid 下表示不钳制。
  6. 边界:空加成列表 → 返回 base;未知 combine 字符串 → push_error 并回退 hybrid

接线类(运行时实测)

  1. cores.jsoncpu_limit 生效:矩阵板(8) → MAX_OPS 320、高速法杖(3) → 120(改前恒为 200)。
  2. 现有平衡零改动:小木法杖(5) 仍为 MAX_OPS 200。
  3. 换杖时加成来源正确轮换:小木(5) → 矩阵板(8) 后 PlayerStats.cpu_limit 为 8 而非 13(证明 remove_modifiers_from("core") 先于 add_modifier 生效,旧法杖份额未累积)。
  4. move_speed 数据驱动:改 attributes.json 后玩家实际移速跟随变化。
  5. cast_delay_mod = 0.5 → 实际 _cast_interval 减半。
  6. remove_modifiers_from(source) 精确撤销该来源的全部加成且重算正确 —— 混入一条他源加成,撤销 "core" 后他源仍在。

清理与规范

  1. armor / resistance 全项目零残留(含存档 to_dict/from_dict)。
  2. 设计器「属性」页对真实 data/attributes.json 回读往返一致;combine 下拉存 key 不存显示串。
  3. 解析通过(class_name 脚本用 GDScript.reload() 绕法验证);纯数据驱动,文件缺失时 push_error 明确报错不静默回退。

5. 非目标(YAGNI

  • attunement_×4 / luck / recharge_speed_mod —— 见 §0,各自独立立项。
  • mana_max 的「与法杖取 Min」语义 —— 见 §2.2,独立设计判断。
  • 打通暴击链 —— 见 §1.3luck 的前置。
  • 货架 C(属性购买 UI 与经济)—— 子计划 ②;本期只提供 add_modifier 接入点。
  • 升级时自动加属性 —— 本期不引入任何加成来源。
  • 玩家侧 armor/resistance —— 见 §2.6,删除并记录立项路径。
  • 存档迁移 —— armor/resistance 从未被写入过有意义的值,旧存档读取时忽略即可,不需要 schema 版本号提升。