Files
spellforge/scripts/autoloads/player_stats.gd
T
joywayerandClaude Opus 5 622da4a18e feat(shop): 货架 C 状态与购买——shop 段驱动可售集合,唯一写入点重建加成
可售集合纯数据驱动:attributes.json 的 shop 段缺失即不可售,将来解除 7 个
被阻塞属性的前置后只需加一段 JSON,零代码。

PlayerStats 新增通用生效值访问器 get_attr_value(attr_id),消除商店/UI 侧
本应出现的三处硬编码 match attr_id;_attr_effective 与裸字段在同一处
(_recompute_attrs)更新,避免两者不同步导致显示值与生效值脱节。

_attr_purchases 只存次数;每属性至多一条 _modifiers、value 为合并值
(pct 下 n 次 +step 等价于单条 (1+step)^n − 1)。这样出售退款只需次数减一后
重算,不必给 PlayerStats 新增按条撤销的 API。

所有改动次数的路径都收在 _apply_attr_purchases()——两份状态不同步会产生
「显示买了 3 次但加成只有 2 次」且无任何诊断,收敛写入点是唯一防线。

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

266 lines
12 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"
## 法杖运算力份额的加成来源标识(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])