_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>
212 lines
8.0 KiB
GDScript
212 lines
8.0 KiB
GDScript
## 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 合并必须按属性的 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,此处保证独立调用时也正确)
|