get_sellable_attrs() 此前只看 attributes.json 是否有 shop 段,不看 PlayerStats 是否真的实现了该属性。运行时实测:给一个合成属性加 shop 段(不改任何代码)会被列出、 可买、扣钱、购买计数增加,但 get_attr_value 永远读到 0.00——玩家买到的是空气, 且 _at_soft_cap 因为 0.0 < soft 永远不封顶,变相无限花钱买无效果。 PlayerStats 新增 has_attr(attr_id) 谓词(查 _attr_effective,只有 _recompute_attrs 真正算过的属性才算数,不是查 _attr_def 有没有这一节);get_sellable_attrs 用它做 第二道过滤。诊断特意拆进独立函数 _report_unwired_shop_attrs 才调用 push_error+assert—— 实测 assert(false) 在本项目运行环境下会让「当前函数」提前返回声明类型的默认值:若诊断写 在收集循环里,一旦命中就会让 get_sellable_attrs 本身连同已收集好的合法属性一起返回空数组, 比原来的「静默卖空气」更糟(整个货架消失);拆成独立函数调用后,中断只发生在诊断函数 自己的调用帧内,调用方(get_sellable_attrs)仍会正常继续并返回过滤后的正确列表。 已注入未接线属性验证:4 个合法属性照常出现,注入项被排除,无一致性问题。 顺带订正路线图 docs_dev/plans/2026-07-23-missing-features-roadmap.md 里「加 shop 段 即可上架、零代码」的表述——该结论只在属性已先接入 PlayerStats 框架(player_stats.gd 的裸字段 + _recompute_attrs 分支 + _attr_effective 条目,以及 attribute_tab.gd 的 ATTR_ORDER)之后才成立,E3-① 延后的 7 个属性都还没有这一步;同时记录商店属性子面板 当前坐标最多容纳 6 行的限制,供后续实现者提前规划。
243 lines
10 KiB
GDScript
243 lines
10 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 段「且」已在 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 "该属性不可购买"
|
||
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,此处保证独立调用时也正确)
|