From af9c94db252bd883eb20b0321e3941bb36d733ec Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 31 Jul 2026 11:24:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(attr):=20add=5Fmodifier=20=E8=A1=A5?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E3=80=81hp=20=E9=9A=8F=20hp=5Fmax=20?= =?UTF-8?q?=E8=B0=83=E5=92=8C=E3=80=81reset=5Ffor=5Frun=20=E6=B8=85?= =?UTF-8?q?=E5=8A=A0=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审 Changes needed 的三个 Important + 五条 Minor: 1. add_modifier 补 attr_id / mode 校验。AttributeFormula 的注释明文把校验委托 给本函数("仅由 PlayerStats.add_modifier 构造…此处不做防御性校验"),而它 原先不守任何门:打错的 attr_id 会永远堆在 _modifiers 里、永不被读到、零诊断。 Task 3 的 add_modifier("cpu_limit",…) 若打成 "cpu_limits" 即 MAX_OPS=0, 所有法术静默执行零步。 2. _recompute_attrs 补 hp = minf(hp, hp_max)。hp_max 改动前事实上不可变,现在 是可动派生值而 hp 从不调和:买 +100 hp_max、血 180/200、卖掉 → 显示 180/100、 get_hp_percent 返回 1.8、heal 静默失效。 3. reset_for_run 清 _modifiers 并重算,且必须早于 hp = hp_max,否则 hp 用陈旧 的 hp_max 播种。今天 "core" 靠 _rebuild_wand 自愈,但货架 C 的购买会跨局白嫖。 Minor:_ATTR_PATH → ATTRIBUTES_JSON 并上移(_PATH 后缀在本项目专指 user:// 路径, 同类 autoload 一律 XXX_JSON);_mods_for 返回 Array[Dictionary];加载器逐条守卫 畸形值("move_speed": 200 会让 Dictionary 赋值硬崩);remove_modifiers_from 无 命中时提前返回不空发 stats_changed;货架 C 提示并入 get_save_data 并补记回读须 用 .assign()(无类型 Array 赋给 Array[Dictionary] 是运行时错误)。 _recompute_attrs 的空定义分支用 assert 而非 push_error:Task 3 接线后每次换杖/ 换牌/购买都触发重算,无条件报错一局刷上百行、反而埋掉根因;assert 在 release 被编译掉且开发期首次调用即中断。 Co-Authored-By: Claude Opus 5 --- scripts/autoloads/player_stats.gd | 52 +++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/scripts/autoloads/player_stats.gd b/scripts/autoloads/player_stats.gd index d60b502..e5af11b 100644 --- a/scripts/autoloads/player_stats.gd +++ b/scripts/autoloads/player_stats.gd @@ -5,6 +5,8 @@ extends Node signal stats_changed signal leveled_up(new_level: int) +const ATTRIBUTES_JSON: String = "res://data/attributes.json" + # ── HP ─────────────────────────────────────────────── var hp: float = 100.0 var hp_max: float = 100.0 @@ -19,7 +21,8 @@ var xp_to_next: int = 10 # 升级所需 XP # ── 属性(生效值:静态类型裸字段,读取端零开销)───────────── # 由 _recompute_attrs() 经 AttributeFormula 从 base + _modifiers 算出,勿直接赋值 -var cpu_limit: int = 0 # 生效运算力(含法杖 "core" 加成);MAX_OPS = 本值 × 40 +var cpu_limit: int = 0 # 生效运算力;MAX_OPS = 本值 × 40 + # 法杖份额待 combat_manager 以 source="core" 注入(Task 3 接线后) var move_speed: float = 200.0 # 像素/秒 var cast_delay_mod: float = 1.0 # 施法间隔乘算系数,越低越快 var _invuln_until_msec: int = 0 # < now 表示可受击;受击后设为 now + iframe 窗口 @@ -130,6 +133,8 @@ func spend_hp_cost(amount: float) -> void: EventBus.emit(EventID.PLAYER_DIED, {}) func reset_for_run() -> void: + _modifiers.clear() # 局内加成不跨局;"core" 随后由 _rebuild_wand 重建 + _recompute_attrs() # 必须早于 hp = hp_max,否则 hp 会用陈旧的 hp_max 播种 hp = hp_max mana = mana_max gold = 0 @@ -141,6 +146,12 @@ func reset_for_run() -> void: ## hp_max / cpu_limit 不入存档:二者现为 attributes.json + _modifiers 的派生值, ## 回读会覆盖公式结果。旧存档里的同名键忽略即可,无需提升 schema 版本。 +## +## 留给货架 C:一旦玩家可购买属性,需要持久化的是 _modifiers(来源列表)而非派生的 +## 生效值,届时本函数应加 "attr_modifiers": _modifiers 并提升 schema 版本。 +## · source == "core" 的那条**不要**持久化——它在换杖时由 combat_manager._rebuild_wand 重建。 +## · 回读须用 _modifiers.assign(...):JSON.parse_string / data.get(..., []) 产出的是无类型 +## Array,直接 `=` 赋给 Array[Dictionary] 会运行时类型错误。 func get_save_data() -> Dictionary: return {"hp": hp, "gold": gold, "xp": xp, "level": level} @@ -153,20 +164,32 @@ func load_save_data(data: Dictionary) -> void: stats_changed.emit() # ── 属性框架 ────────────────────────────────────────────── -const _ATTR_PATH: String = "res://data/attributes.json" func _load_attr_definitions() -> void: - if not FileAccess.file_exists(_ATTR_PATH): - push_error("PlayerStats: 缺少 %s" % _ATTR_PATH) + if not FileAccess.file_exists(ATTRIBUTES_JSON): + push_error("PlayerStats: 缺少 %s" % ATTRIBUTES_JSON) return - var parsed = JSON.parse_string(FileAccess.get_file_as_string(_ATTR_PATH)) + var parsed = JSON.parse_string(FileAccess.get_file_as_string(ATTRIBUTES_JSON)) if not (parsed is Dictionary): - push_error("PlayerStats: %s 格式错误(应为对象)" % _ATTR_PATH) + push_error("PlayerStats: %s 格式错误(应为对象)" % ATTRIBUTES_JSON) return + # 逐条守卫:畸形条目(如 "move_speed": 200)会让 _compute_attr 的 Dictionary 赋值硬崩 + for k in parsed: + if not (parsed[k] is Dictionary): + push_error("PlayerStats: attributes.json 的「%s」应为对象" % k) + return _attr_def = parsed ## 追加一条加成来源;同一 source 可对多个属性各加一条 +## 本函数是 AttributeFormula.compute 的守门人——公式模块明文假定 mode/value 合法且不再校验, +## 故非法 attr_id / mode 必须在此拦下:否则错条目会永远堆在 _modifiers 里且不产生任何诊断 func add_modifier(attr_id: String, mode: String, value: float, source: String) -> void: + if not _attr_def.has(attr_id): + push_error("PlayerStats: 未知属性「%s」(来源 %s),加成已忽略" % [attr_id, source]) + return + if mode != "flat" and mode != "pct": + push_error("PlayerStats: 未知 mode「%s」(%s→%s),加成已忽略" % [mode, source, attr_id]) + return _modifiers.append({"attr_id": attr_id, "mode": mode, "value": value, "source": source}) _recompute_attrs() @@ -176,28 +199,31 @@ func remove_modifiers_from(source: String) -> void: for m in _modifiers: if String(m.get("source", "")) != source: kept.append(m) + if kept.size() == _modifiers.size(): + return # 无命中:不 emit stats_changed,避免无谓的 HUD / 商店刷新 _modifiers = kept _recompute_attrs() -func _mods_for(attr_id: String) -> Array: - var out: Array = [] +func _mods_for(attr_id: String) -> Array[Dictionary]: + var out: Array[Dictionary] = [] for m in _modifiers: if String(m.get("attr_id", "")) == attr_id: out.append(m) return out -## 逐属性按各自 combine 公式重算生效值,写回裸字段 -## 留给货架 C:一旦玩家可购买属性,需要持久化的是 _modifiers(来源列表)而非 -## 派生的生效值,届时 get_save_data 应加 "attr_modifiers": _modifiers 并提升 -## schema 版本。source == "core" 的那条**不要**持久化——它在换杖时由 -## combat_manager._rebuild_wand 重建。 +## 逐属性按各自 combine 公式重算生效值,写回裸字段(加成增删后调用) func _recompute_attrs() -> void: if _attr_def.is_empty(): + # 定义未加载,根因已由 _load_attr_definitions push_error。此处不重复报错:本函数在 + # 换杖/换牌/购买时被频繁调用,无条件报错会刷屏并埋掉那条根因。assert 在 release 被 + # 编译掉,开发期则由 _ready 的首次调用立即中断。 + assert(false, "PlayerStats: 属性定义未加载,add_modifier / 重算全部失效") return cpu_limit = int(_compute_attr("cpu_limit")) move_speed = _compute_attr("move_speed") cast_delay_mod = _compute_attr("cast_delay_mod") hp_max = _compute_attr("hp_max") + hp = minf(hp, hp_max) # hp_max 下调(出售退款/换杖)时避免 hp > hp_max stats_changed.emit() func _compute_attr(attr_id: String) -> float: