Files
spellforge/scripts/autoloads/player_stats.gd
T
joywayerandClaude Opus 5 69ddcddba6 docs(attr): 补记货架 C 的回读顺序陷阱;畸形条目报错说明整文件已拒绝
纯注释改动,无行为变更。

1. get_save_data 的货架 C 文档块补回读顺序:_modifiers 必须先 assign +
   _recompute_attrs(),再赋 hp。反过来会让新加的 hp = minf(hp, hp_max) 拿
   未加成的 hp_max 去钳,静默吞血(评审复现:回读 hp=180/100 → 换杖重算后
   100/100,丢 80 HP)。今天不可达(hp_max 恒 100),但持久化 _modifiers 一
   落地即活,而这个文档块正是那位实现者会读的地方。

2. 畸形条目的 push_error 补「整个文件已拒绝加载」——守卫是 return,一个坏
   条目会让四个属性全部退回声明默认值,原文案读起来像只影响那一个键。

3. ATTRIBUTES_JSON 补「数据源」分区横幅,与文件自身风格一致。

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

238 lines
9.8 KiB
GDScript
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.
## PlayerStats — 玩家属性统计(Autoload: PlayerStats
## 权威来源:development_plan.md S2、numerical_design.md §1.2
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
# ── 资源 ──────────────────────────────────────────────
var gold: int = 0
var xp: int = 0
# ── 等级 ──────────────────────────────────────────────
var level: int = 1
var xp_to_next: int = 10 # 升级所需 XP
# ── 属性(生效值:静态类型裸字段,读取端零开销)─────────────
# 由 _recompute_attrs() 经 AttributeFormula 从 base + _modifiers 算出,勿直接赋值
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 窗口
# 属性定义与加成来源(非热路径)
var _attr_def: Dictionary = {} # attributes.json 全量定义,只读
var _modifiers: Array[Dictionary] = [] # [{attr_id, mode, value, source}]
# ── 魔力(MVP)─────────────────────────────────────────
var mana: float = 100.0
var mana_max: float = 100.0
var mana_regen: float = 5.0 # 每秒回复
var mana_heat: float = 0.0 # 递增蓝耗热值(0=无,1=蓝耗翻倍)
var mana_leech: float = 0.0 # 击杀回蓝量(换杖/换牌重算)
func _ready() -> void:
_load_attr_definitions()
_recompute_attrs()
EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed)
func _on_enemy_killed(_payload: Dictionary) -> void:
gain_mana(mana_leech)
## 升级曲线:roundf(10 × 1.4^(level-1))P6-N16
## Level 1→2: 10 XP; Level 5→6: ≈ 54 XP
static func xp_for_level(lv: int) -> int:
return roundi(10.0 * pow(1.4, float(lv - 1)))
func gain_xp(amount: int) -> void:
xp += amount
while xp >= xp_to_next:
xp -= xp_to_next
level += 1
xp_to_next = xp_for_level(level)
leveled_up.emit(level)
EventBus.emit(EventID.LEVEL_UP, {"level": level})
stats_changed.emit()
func gain_gold(amount: int) -> void:
gold += amount
stats_changed.emit()
func spend_gold(amount: int) -> bool:
if gold < amount:
return false
gold -= amount
stats_changed.emit()
return true
func take_damage(amount: float) -> void:
var now: int = Time.get_ticks_msec()
if now < _invuln_until_msec:
return # 无敌窗口内:忽略外部伤害(多弹同帧只扣一次)
var applied: float = amount * SettingsManager.player_dmg_taken_mult() # 难度减伤(初学者×0.7
hp = max(0.0, hp - applied)
_invuln_until_msec = now + int(SettingsManager.player_iframe_sec() * 1000.0)
EventBus.emit(EventID.PLAYER_DAMAGED, {"damage": applied}) # 复活 hurt 音效 / HUD 钩子
stats_changed.emit()
if hp <= 0.0:
EventBus.emit(EventID.PLAYER_DIED, {})
func is_invulnerable() -> bool:
return Time.get_ticks_msec() < _invuln_until_msec
func heal(amount: float) -> void:
hp = min(hp_max, hp + amount)
stats_changed.emit()
func get_hp_percent() -> float:
return hp / max(hp_max, 0.001)
## 换杖时设定蓝池上限/回速,并把当前蓝夹到 [0, max]
func set_mana_pool(max_v: float, regen: float) -> void:
mana_max = max_v
mana_regen = regen
mana = clampf(mana, 0.0, mana_max)
stats_changed.emit()
## 花费魔力;够则扣除返回 true,否则不变返回 false
func spend_mana(cost: float) -> bool:
if mana < cost:
return false
mana -= cost
stats_changed.emit()
return true
## 每帧回蓝(PlayerManager 驱动)。不 emit stats_changed —— HUD 每帧直接读 mana
## 避免回蓝过程每帧发信号触发商店刷新。
func regen_mana(delta: float) -> void:
if mana >= mana_max:
return
mana = min(mana_max, mana + mana_regen * delta)
## 击杀回蓝(魔力虹吸)
func gain_mana(n: float) -> void:
if n <= 0.0:
return
mana = min(mana_max, mana + n)
stats_changed.emit()
## 主动 HP 代价(infinite_spells / heavy_cost)——直接扣血,不走难度减伤
func spend_hp_cost(amount: float) -> void:
if amount <= 0.0:
return
hp = max(0.0, hp - amount)
stats_changed.emit()
if hp <= 0.0:
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
xp = 0
level = 1
xp_to_next = xp_for_level(1)
_invuln_until_msec = 0
stats_changed.emit()
## 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] 会运行时类型错误。
## · 回读顺序:_modifiers 必须先 assign + _recompute_attrs(),再赋 hp。反过来会让
## _recompute_attrs 的 hp = minf(hp, hp_max) 拿未加成的 hp_max 去钳,静默吞血。
func get_save_data() -> Dictionary:
return {"hp": hp, "gold": gold, "xp": xp, "level": level}
func load_save_data(data: Dictionary) -> void:
hp = float(data.get("hp", 100.0))
gold = int(data.get("gold", 0))
xp = int(data.get("xp", 0))
level = int(data.get("level", 1))
xp_to_next = xp_for_level(level)
stats_changed.emit()
# ── 属性框架 ──────────────────────────────────────────────
func _load_attr_definitions() -> void:
if not FileAccess.file_exists(ATTRIBUTES_JSON):
push_error("PlayerStats: 缺少 %s" % ATTRIBUTES_JSON)
return
var parsed = JSON.parse_string(FileAccess.get_file_as_string(ATTRIBUTES_JSON))
if not (parsed is Dictionary):
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()
## 撤销某来源的全部加成(换杖、出售退款用)
func remove_modifiers_from(source: String) -> void:
var kept: Array[Dictionary] = []
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[Dictionary]:
var out: Array[Dictionary] = []
for m in _modifiers:
if String(m.get("attr_id", "")) == attr_id:
out.append(m)
return out
## 逐属性按各自 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:
var d: Dictionary = _attr_def.get(attr_id, {})
if d.is_empty():
push_error("PlayerStats: attributes.json 缺少属性「%s」" % attr_id)
return 0.0
return AttributeFormula.compute(float(d.get("base", 0.0)), _mods_for(attr_id), d)