Files
spellforge/scripts/autoloads/player_stats.gd
T
joywayer 5889b3c1e5 fix(shop): 可售属性排除未接线项,修正路线图「零代码」表述
get_sellable_attrs() 此前只看 attributes.json 是否有 shop 段,不看 PlayerStats
是否真的实现了该属性。运行时实测:给一个合成属性加 shop 段(不改任何代码)会被列出、
可买、扣钱、购买计数增加,但 get_attr_value 永远读到 0.00——玩家买到的是空气,
且 _at_soft_cap 因为 0.0 < soft 永远不封顶,变相无限花钱买无效果。

PlayerStats 新增 has_attr(attr_id) 谓词(查 _attr_effective,只有 _recompute_attrs
真正算过的属性才算数,不是查 _attr_def 有没有这一节);get_sellable_attrs 用它做
第二道过滤。诊断特意拆进独立函数 _report_unwired_shop_attrs 才调用 push_error+assert——
实测 assert(false) 在本项目运行环境下会让「当前函数」提前返回声明类型的默认值:若诊断写
在收集循环里,一旦命中就会让 get_sellable_attrs 本身连同已收集好的合法属性一起返回空数组,
比原来的「静默卖空气」更糟(整个货架消失);拆成独立函数调用后,中断只发生在诊断函数
自己的调用帧内,调用方(get_sellable_attrs)仍会正常继续并返回过滤后的正确列表。
已注入未接线属性验证:4 个合法属性照常出现,注入项被排除,无一致性问题。

顺带订正路线图 docs_dev/plans/2026-07-23-missing-features-roadmap.md 里「加 shop 段
即可上架、零代码」的表述——该结论只在属性已先接入 PlayerStats 框架(player_stats.gd
的裸字段 + _recompute_attrs 分支 + _attr_effective 条目,以及 attribute_tab.gd 的
ATTR_ORDER)之后才成立,E3-① 延后的 7 个属性都还没有这一步;同时记录商店属性子面板
当前坐标最多容纳 6 行的限制,供后续实现者提前规划。
2026-08-03 12:51:13 +08:00

275 lines
13 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])
## 该属性是否已在框架内实装(即 _recompute_attrs 会为它算出生效值)。
## 供 ShopManager 等外部读者判定:只有 attributes.json 有 shop 段还不够卖——
## 若该属性根本没接进 PlayerStats(无裸字段/无 _recompute_attrs 分支/无 _attr_effective 条目),
## 卖出的加成会调用 add_modifier 写入 _modifiers 但永远没有对应的 _compute_attr 分支读取它,
## 玩家花钱买了一个不生效的空气条目。用 _attr_effective(而非 _attr_def)判定,因为
## _attr_def 只反映 JSON 有没有这一节、不反映代码有没有真的消费它。
func has_attr(attr_id: String) -> bool:
return _attr_effective.has(attr_id)