## 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 段的属性。缺段即不可售(数据驱动,加属性零代码) 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,此处保证独立调用时也正确)