## 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_count(P6-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)^n−1 # inverse: 需 f=(1−step)^n → merged=1−(1−step)^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,此处保证独立调用时也正确)