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>
This commit is contained in:
@@ -10,12 +10,20 @@ const SLOT_COUNT: int = 3
|
||||
const REROLL_BASE_COST: int = 20 # 第 1 次刷新费用
|
||||
const REROLL_STEP: int = 10 # 每次递增价格
|
||||
|
||||
## 货架 C 加成来源标识。必须用常量而非裸字面量:来源串在 remove/add 两侧必须逐字相同——
|
||||
## remove 那侧打错一个字符,旧份额就不会被撤销而是逐次累积(理由同 PlayerStats.MOD_SOURCE_CORE)
|
||||
const MOD_SOURCE_SHOP_C: String = "shop_c"
|
||||
const ATTRIBUTES_JSON: String = "res://data/attributes.json"
|
||||
|
||||
var current_slots: Array = [] # Array[SpellNode] (null 表示已售出)
|
||||
var reroll_count: int = 0 # 本波已刷新次数(WAVE_COMPLETE 后重置)
|
||||
var _shop_seed: int = 0 # 随机种(ADR-A2 P-S2-07)
|
||||
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
|
||||
var _attr_purchases: Dictionary = {} # attr_id(String) → 已购次数(int);唯一写入点 _apply_attr_purchases()
|
||||
var _attr_def: Dictionary = {} # attributes.json 全量定义,只读
|
||||
|
||||
func _ready() -> void:
|
||||
_load_attr_definitions()
|
||||
EventBus.subscribe(EventID.WAVE_COMPLETE, _on_wave_complete)
|
||||
|
||||
func _on_wave_complete(_payload: Dictionary) -> void:
|
||||
@@ -86,7 +94,110 @@ func close_shop() -> void:
|
||||
func get_shop_seed() -> int:
|
||||
return _shop_seed
|
||||
|
||||
# ── 货架 C:属性购买 ──────────────────────────────────────
|
||||
func _load_attr_definitions() -> void:
|
||||
if not FileAccess.file_exists(ATTRIBUTES_JSON):
|
||||
push_error("ShopManager: 缺少 %s" % ATTRIBUTES_JSON)
|
||||
return
|
||||
var parsed = JSON.parse_string(FileAccess.get_file_as_string(ATTRIBUTES_JSON))
|
||||
if not (parsed is Dictionary):
|
||||
push_error("ShopManager: %s 格式错误(应为对象)" % ATTRIBUTES_JSON)
|
||||
return
|
||||
for k in parsed:
|
||||
if not (parsed[k] is Dictionary):
|
||||
push_error("ShopManager: attributes.json 的「%s」应为对象,整个文件已拒绝加载" % k)
|
||||
return
|
||||
_attr_def = parsed
|
||||
|
||||
## 可售属性 = 带 shop 段的属性。缺段即不可售(数据驱动,加属性零代码)
|
||||
func get_sellable_attrs() -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
for id in _attr_def:
|
||||
if _attr_def[id].get("shop", null) is Dictionary:
|
||||
out.append(String(id))
|
||||
out.sort()
|
||||
return out
|
||||
|
||||
func get_attr_purchases(attr_id: String) -> int:
|
||||
return int(_attr_purchases.get(attr_id, 0))
|
||||
|
||||
func get_attr_price(attr_id: String) -> int:
|
||||
var sp = _attr_def.get(attr_id, {}).get("shop", null)
|
||||
if not (sp is Dictionary):
|
||||
return 0
|
||||
return PriceFormula.compute(sp, get_attr_purchases(attr_id))
|
||||
|
||||
## 返回 "" 表示可买;否则为禁用原因(UI 直接显示,不要只灰掉按钮)
|
||||
func can_buy_attribute(attr_id: String) -> String:
|
||||
var d: Dictionary = _attr_def.get(attr_id, {})
|
||||
var sp = d.get("shop", null)
|
||||
if not (sp is Dictionary):
|
||||
return "该属性不可购买"
|
||||
if _at_soft_cap(attr_id, d):
|
||||
return "已达上限"
|
||||
if PlayerStats.gold < get_attr_price(attr_id):
|
||||
return "金币不足"
|
||||
return ""
|
||||
|
||||
## 已达 soft 上限?soft 是「常规来源可达上限」(E3-① 保留该字段正为此)
|
||||
## inverse 属性越低越好,故方向相反
|
||||
func _at_soft_cap(attr_id: String, d: Dictionary) -> bool:
|
||||
var soft: float = float(d.get("soft", 0.0))
|
||||
if soft <= 0.0:
|
||||
return false
|
||||
var cur: float = PlayerStats.get_attr_value(attr_id)
|
||||
if String(d.get("combine", "hybrid")) == "inverse":
|
||||
return cur <= soft
|
||||
return cur >= soft
|
||||
|
||||
## 属性定义(供 UI 读 display_name 等,避免在 UI 侧再复制一份属性名表)
|
||||
func get_attr_def(attr_id: String) -> Dictionary:
|
||||
var d = _attr_def.get(attr_id, {})
|
||||
return d if d is Dictionary else {}
|
||||
|
||||
func buy_attribute(attr_id: String) -> bool:
|
||||
var reason: String = can_buy_attribute(attr_id)
|
||||
if not reason.is_empty():
|
||||
return false
|
||||
var cost: int = get_attr_price(attr_id)
|
||||
if not PlayerStats.spend_gold(cost):
|
||||
return false
|
||||
_attr_purchases[attr_id] = get_attr_purchases(attr_id) + 1
|
||||
_apply_attr_purchases()
|
||||
shop_refreshed.emit()
|
||||
return true
|
||||
|
||||
## 唯一写入点:所有改动 _attr_purchases 的路径(购买 / 出售退款 / 存档回读 / reset)
|
||||
## 都必须经此重建加成。两份状态不同步会产生「显示买了 3 次但加成只有 2 次」且无任何诊断
|
||||
func _apply_attr_purchases() -> void:
|
||||
PlayerStats.remove_modifiers_from(MOD_SOURCE_SHOP_C)
|
||||
for id in _attr_purchases:
|
||||
var n: int = int(_attr_purchases[id])
|
||||
if n <= 0:
|
||||
continue
|
||||
var sp = _attr_def.get(id, {}).get("shop", null)
|
||||
if not (sp is Dictionary):
|
||||
push_error("ShopManager: 「%s」有购买记录但无 shop 段,加成已跳过" % id)
|
||||
continue
|
||||
var mode: String = String(sp.get("mode", "flat"))
|
||||
var step: float = float(sp.get("step", 0.0))
|
||||
# pct 合并:n 次 +step 在连乘下等价于单条 (1+step)^n − 1
|
||||
var merged: float = (pow(1.0 + step, float(n)) - 1.0) if mode == "pct" else (step * float(n))
|
||||
PlayerStats.add_modifier(id, mode, merged, MOD_SOURCE_SHOP_C)
|
||||
|
||||
## 存档:只存次数,加成是派生物(回读时经 _apply_attr_purchases 重建)
|
||||
func get_attr_purchases_save() -> Dictionary:
|
||||
return _attr_purchases.duplicate()
|
||||
|
||||
func apply_attr_purchases_save(d: Dictionary) -> void:
|
||||
_attr_purchases.clear()
|
||||
for k in d:
|
||||
_attr_purchases[String(k)] = int(d[k])
|
||||
_apply_attr_purchases()
|
||||
|
||||
func reset() -> void:
|
||||
current_slots.clear()
|
||||
reroll_count = 0
|
||||
_shop_seed = 0
|
||||
_attr_purchases.clear()
|
||||
_apply_attr_purchases() # 撤销 shop_c 加成(PlayerStats.reset_for_run 已清 _modifiers,此处保证独立调用时也正确)
|
||||
|
||||
Reference in New Issue
Block a user