Files
spellforge/scripts/autoloads/player_stats.gd
T
joywayerandClaude Opus 5 9361bf8767 refactor(attr): "core" 来源提为常量;_compute_attr 补 assert;注明 execute_sub 预算独立
MOD_SOURCE_CORE 常量取代四处裸字面量(combat_manager:275,276、combat_test:20,21)。
combat_manager:273 的注释自己就警告「漏掉 remove 会累积,且 hard:50 会把它封成一个
看起来合理的数字」——而 remove 那一侧的字面量若打错一个字符,产生的正是这个零诊断
故障:旧份额撤不掉、逐次累积、被硬上限封顶成常数。常量把它变成编译期错误。

_compute_attr 缺定义时返回 0.0 是消费侧的不对称:Task 4 已硬化生产侧(attribute_tab
的 _loaded 拒绝写出零载荷),但手删 JSON 里的 cast_delay_mod 仍会让生效间隔变 0
(每物理帧施法一次)、删 move_speed 则玩家不能动。它有 push_error,但运行时 push_error
到不了任何日志通道(本期已实测),故表现为一块莫名其妙的砖。按 _recompute_attrs 空定义
分支的同款模式补 assert:release 编译掉、开发期立刻中断。

spell_evaluator:332 的新注释说「生效 cpu_limit 已含法杖份额」,读者可能推断整个 VM 都按
cpu_limit 走,但 execute_sub 的子荷载预算仍是独立的 MAX_OPS_PER_CPU * 2(=80)。补一句
注明其独立且属既有行为(改前同样脱节),不改 execute_sub 的行为。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:37:15 +08:00

250 lines
11 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}]
# ── 魔力(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():
# 返回 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)