评审 Changes needed 的三个 Important + 五条 Minor:
1. add_modifier 补 attr_id / mode 校验。AttributeFormula 的注释明文把校验委托
给本函数("仅由 PlayerStats.add_modifier 构造…此处不做防御性校验"),而它
原先不守任何门:打错的 attr_id 会永远堆在 _modifiers 里、永不被读到、零诊断。
Task 3 的 add_modifier("cpu_limit",…) 若打成 "cpu_limits" 即 MAX_OPS=0,
所有法术静默执行零步。
2. _recompute_attrs 补 hp = minf(hp, hp_max)。hp_max 改动前事实上不可变,现在
是可动派生值而 hp 从不调和:买 +100 hp_max、血 180/200、卖掉 → 显示 180/100、
get_hp_percent 返回 1.8、heal 静默失效。
3. reset_for_run 清 _modifiers 并重算,且必须早于 hp = hp_max,否则 hp 用陈旧
的 hp_max 播种。今天 "core" 靠 _rebuild_wand 自愈,但货架 C 的购买会跨局白嫖。
Minor:_ATTR_PATH → ATTRIBUTES_JSON 并上移(_PATH 后缀在本项目专指 user:// 路径,
同类 autoload 一律 XXX_JSON);_mods_for 返回 Array[Dictionary];加载器逐条守卫
畸形值("move_speed": 200 会让 Dictionary 赋值硬崩);remove_modifiers_from 无
命中时提前返回不空发 stats_changed;货架 C 提示并入 get_save_data 并补记回读须
用 .assign()(无类型 Array 赋给 Array[Dictionary] 是运行时错误)。
_recompute_attrs 的空定义分支用 assert 而非 push_error:Task 3 接线后每次换杖/
换牌/购买都触发重算,无条件报错一局刷上百行、反而埋掉根因;assert 在 release
被编译掉且开发期首次调用即中断。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
235 lines
9.4 KiB
GDScript
235 lines
9.4 KiB
GDScript
## 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] 会运行时类型错误。
|
||
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)
|