Files
spellforge/scripts/autoloads/shop_manager.gd
T
joywayerandClaude Opus 5 f8e02b0a0b fix(shop): pct 合并公式按 combine 分支取值,修正 inverse 属性算错
_apply_attr_purchases() 原公式 merged=(1+step)^n−1 只对 AttributeFormula 的
hybrid 分支(f=1+v)成立;inverse 分支用 f=1−v,四个可售属性里 cast_delay_mod
恰是 inverse+pct,实际把 f 算成 2−1.1^n 而非设计意图的 0.9^n——n=7 时应得
0.478,实得 ≈0.0513,可购买次数从设计的约 22 次被砍到约 7 次即撞 soft,且
全程 f≥0 走不到越界钳制分支,零诊断。

这是 Task 2 简报 spec 本身推导错误(简报只在 hybrid 下验证过等价性),非
实现偏差;评审已订正 spec 与计划(68e3edb)。现按订正后的公式补上按
combine 分支取值:hybrid 用 merged=(1+step)^n−1,inverse 用
merged=1−(1−step)^n,flat 与 combine 无关不变。

补测显式覆盖此前遗漏的 inverse+pct 组合(此前运行时验证只买过
move_speed/hp_max/cpu_limit,唯独没买过 cast_delay_mod——测试盲区精确盖住
了 bug 所在处):n=1..3/7 逐点数值比对(容差 1e-6),并确认 soft 封顶购买
次数从 ~7 恢复到 ~22;hybrid/flat 对照组数值不变。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:19:23 +08:00

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