## 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" ## 法杖运算力份额的加成来源标识(combat_manager._rebuild_wand / combat_test 注入时使用) ## 必须用常量而非裸字面量:"core" 在 remove/add 两侧必须逐字相同——remove 那侧打错一个字符, ## 旧份额就不会被撤销而是逐次累积,且 cpu_limit 的 hard(50) 会把累积值封成一个看起来合理的 ## 数字(不是显眼的荒谬值),故障零诊断且极难发现。常量把这个风险变成编译期错误。 const MOD_SOURCE_CORE: String = "core" # ── 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}] var _attr_effective: Dictionary = {} # attr_id → 生效值;供冷路径(商店/UI/设计器)通用读取 # ── 魔力(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 # 冷路径通用视图:热路径(move_speed 每物理帧)仍走裸字段,此表只服务商店/UI 等按 id 取值的场景。 # 必须与裸字段在同一处更新——两者不同步会让商店显示的值与实际生效值不一致且无诊断。 _attr_effective = { "cpu_limit": float(cpu_limit), "move_speed": move_speed, "cast_delay_mod": cast_delay_mod, "hp_max": hp_max, } stats_changed.emit() func _compute_attr(attr_id: String) -> float: var d: Dictionary = _attr_def.get(attr_id, {}) if d.is_empty(): # 返回 0.0 的后果按属性而异,但都是「莫名其妙的一块砖」:缺 cast_delay_mod → 生效间隔 0 # → 每物理帧施法一次;缺 move_speed → 玩家不能动;缺 hp_max → 进场即死。 # Task 4 已硬化生产侧(attribute_tab 的 _loaded 拒绝写出零载荷),这里是消费侧对称的那一半。 # assert 与 _recompute_attrs 空定义分支同款:运行时 push_error 到不了任何日志通道 #(已实测,见 plan 工具坑 ⑧),只靠它等于没有诊断;assert 在 release 被编译掉、开发期立刻中断。 push_error("PlayerStats: attributes.json 缺少属性「%s」" % attr_id) assert(false, "PlayerStats: 缺少属性「%s」——生效值将为 0,运行时 push_error 不可见" % attr_id) return 0.0 return AttributeFormula.compute(float(d.get("base", 0.0)), _mods_for(attr_id), d) ## 按 id 取生效值(冷路径)。热路径请直接读裸字段(move_speed 等),本函数有字典查找开销。 func get_attr_value(attr_id: String) -> float: if not _attr_effective.has(attr_id): push_error("PlayerStats: 未知属性「%s」,无生效值" % attr_id) return 0.0 return float(_attr_effective[attr_id])