Files
spellforge/scripts/autoloads/shop_manager.gd
T
joywayer 06f0049221 fix(shop): can_buy_attribute 同样校验属性已接线;订正路线图残留表述
购买路径是独立守门人:货架 B / 出售退款等后来者会直接调 can_buy_attribute
而不必先过 get_sellable_attrs,只在展示侧拦截不足以保住不变量。未接线属性
生效值恒为 0,_at_soft_cap 因此永不封顶,可被无限买成空气。

同时把路线图第 99 行残留的「加属性零代码」括注收敛到下一条 ⚠️ 订正,避免
同一条目里两句话互相矛盾。
2026-08-03 13:02:20 +08:00

248 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.
## ShopManager — 商店管理(Autoload: ShopManager
## S2 范围:3 选 1 法术、金币扣减、刷新费用公式(P6-N23)
## 权威来源:development_plan.md S2、numerical_design.md §2.3
extends Node
signal shop_refreshed
signal shop_closed
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:
reroll_count = 0 # P6-N23:波次结算后重置刷新次数
func open_shop(seed_override: int = -1) -> void:
if seed_override >= 0:
_shop_seed = seed_override
else:
_shop_seed = randi()
_rng.seed = _shop_seed
_refresh_slots()
EventBus.emit(EventID.SHOP_OPENED, {"seed": _shop_seed})
## 商店可抽池:shop_cost>0 且(未门控 unlock_cost==0 或 已解锁)
func _available_pool() -> Array:
return SpellRegistry.get_all_ids().filter(func(id):
var s: SpellNode = SpellRegistry.get_spell(id)
if s == null or int(s.meta.get("shop_cost", 0)) <= 0:
return false
var uc: int = int(s.meta.get("unlock_cost", 0))
return uc == 0 or MetaProgress.is_unlocked(id))
func _refresh_slots() -> void:
var pool: Array = _available_pool()
current_slots.clear()
for _i in SLOT_COUNT:
if pool.is_empty():
current_slots.append(null)
continue
var idx: int = _rng.randi_range(0, pool.size() - 1)
current_slots.append(SpellRegistry.get_spell(pool[idx]))
pool.remove_at(idx)
shop_refreshed.emit()
## 购买指定槽位的法术,返回购买的 SpellNode、失败返回 null
func buy_spell(slot_idx: int) -> SpellNode:
if slot_idx < 0 or slot_idx >= current_slots.size():
return null
var spell: SpellNode = current_slots[slot_idx]
if spell == null:
return null
var cost: int = int(spell.meta.get("shop_cost", 20))
if not PlayerStats.spend_gold(cost):
return null
current_slots[slot_idx] = null
shop_refreshed.emit()
return spell
## 刷新商店,返回 true 表示成功
func reroll() -> bool:
var cost: int = get_reroll_cost()
if not PlayerStats.spend_gold(cost):
return false
reroll_count += 1
_rng.seed = _shop_seed + reroll_count * 31337
_refresh_slots()
return true
## 刷新费用公式:base + step * reroll_countP6-N23
func get_reroll_cost() -> int:
return REROLL_BASE_COST + reroll_count * REROLL_STEP
func close_shop() -> void:
current_slots.clear()
shop_closed.emit()
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 段「且」已在 PlayerStats 框架内实装的属性。只满足前者不够——
## 光有 shop 段而 PlayerStats 未接线(无裸字段/_recompute_attrs 分支/_attr_effective 条目)
## 会导致买了空气:扣钱、_attr_purchases 计数增加,但 PlayerStats.get_attr_value 永远读不到
## 对应的生效值(详见 PlayerStats.has_attr 注释)。数据驱动的「加属性零代码」只在属性已
## 实装的前提下成立,见 docs_dev/plans/2026-07-23-missing-features-roadmap.md 相应条目订正。
func get_sellable_attrs() -> Array[String]:
var out: Array[String] = []
var unwired: Array[String] = []
for id in _attr_def:
if not (_attr_def[id].get("shop", null) is Dictionary):
continue
var attr_id: String = String(id)
if PlayerStats.has_attr(attr_id):
out.append(attr_id)
else:
unwired.append(attr_id) # 先收集,诊断挪到本函数返回之后处理,原因见 _report_unwired_shop_attrs
out.sort()
if not unwired.is_empty():
_report_unwired_shop_attrs(unwired)
return out
## 诊断故意拆成独立函数、且在 get_sellable_attrs 已经算出 out 之后才调用——
## 实测 assert(false) 在本项目运行环境下会当场中断「当前函数」的其余执行并返回该函数声明类型
## 的默认值(此处即空 Array),但不会波及调用方:调用方在函数调用语句之后仍会继续正常执行。
## 若把 push_error/assert 直接写在 get_sellable_attrs 的收集循环里,一旦命中就会让
## get_sellable_attrs 本身在此提前中断,返回空数组——不止是排除了那个坏属性,而是连同
## cpu_limit/move_speed/hp_max/cast_delay_mod 等本来正常的属性也一起从货架上消失,
## 比「静默卖空气」更糟。故诊断必须发生在一次独立的函数调用里,让中断只影响诊断本身。
func _report_unwired_shop_attrs(unwired: Array[String]) -> void:
for attr_id in unwired:
push_error("ShopManager: 属性「%s」有 shop 段但未接入 PlayerStats 框架,已从可售列表排除" % attr_id)
# 运行中的游戏里 push_error 到不了任何日志通道(已实测,见本项目已知工具坑),故同
# player_stats.gd:250-257 的既有做法一样补 assert 保证开发期立刻中断可见。本函数只在
# get_sellable_attrs 检测到不一致时才被调用,而后者只在商店 UI 搭建时调用一次
#combat_s2._setup_attr_shop_ui 在 _ready 调用,不在每次刷新的 _refresh_shop_ui 路径上),
# 故这条 assert 不会刷屏。
assert(false, "ShopManager: shop 段与 PlayerStats 框架不同步:%s" % str(unwired))
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 直接显示,不要只灰掉按钮)
## ⚠️ i18n 债务:以下禁用原因是中文裸串,未经 tr(),不随语言切换——这与法术/核心/状态等
## display_name 现状一致,本期不制造新例外;统一处理见路线图 E7-③「内容名 tr 化」
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 "该属性不可购买"
# 与 get_sellable_attrs 同一条不变量,须在此再判一次:UI 只列可售项,但本函数是购买路径的
# 独立守门人(货架 B / 出售退款等后来者会直接调它,未必先过 get_sellable_attrs)。
# 缺这条则未接线属性可被买成空气,且 _at_soft_cap 因生效值恒为 0 永不封顶 → 可无限购买
if not PlayerStats.has_attr(attr_id):
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 合并必须按属性的 combine 分支——AttributeFormula 对 hybrid 用 f=1+v、对 inverse 用 f=1-v
# 故两者的等价单条值不同。写错会让 inverse 属性(cast_delay_mod)的曲线整体偏离且零诊断。
var combine: String = String(_attr_def.get(id, {}).get("combine", "hybrid"))
var merged: float = 0.0
if mode == "pct":
# hybrid: 需 f=(1+step)^n → merged=(1+step)^n1
# inverse: 需 f=(1step)^n → merged=1(1step)^n
merged = (1.0 - pow(1.0 - step, float(n))) if combine == "inverse" else (pow(1.0 + step, float(n)) - 1.0)
else:
merged = step * float(n) # flat 线性可加,与 combine 无关
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,此处保证独立调用时也正确)