feat(shop): 货架 C 状态与购买——shop 段驱动可售集合,唯一写入点重建加成
可售集合纯数据驱动:attributes.json 的 shop 段缺失即不可售,将来解除 7 个 被阻塞属性的前置后只需加一段 JSON,零代码。 PlayerStats 新增通用生效值访问器 get_attr_value(attr_id),消除商店/UI 侧 本应出现的三处硬编码 match attr_id;_attr_effective 与裸字段在同一处 (_recompute_attrs)更新,避免两者不同步导致显示值与生效值脱节。 _attr_purchases 只存次数;每属性至多一条 _modifiers、value 为合并值 (pct 下 n 次 +step 等价于单条 (1+step)^n − 1)。这样出售退款只需次数减一后 重算,不必给 PlayerStats 新增按条撤销的 API。 所有改动次数的路径都收在 _apply_attr_purchases()——两份状态不同步会产生 「显示买了 3 次但加成只有 2 次」且无任何诊断,收敛写入点是唯一防线。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"cpu_limit": { "display_name": "运算力 cpu_limit", "base": 0.0, "soft": 20.0, "hard": 50.0, "combine": "add_int" },
|
"cpu_limit": { "display_name": "运算力 cpu_limit", "base": 0.0, "soft": 20.0, "hard": 50.0, "combine": "add_int", "shop": { "mode": "flat", "step": 1, "price_base": 120, "price_growth": 1.30, "curve": "geometric" } },
|
||||||
"move_speed": { "display_name": "移动速度 move_speed", "base": 200.0, "soft": 600.0, "hard": 800.0, "combine": "hybrid" },
|
"move_speed": { "display_name": "移动速度 move_speed", "base": 200.0, "soft": 600.0, "hard": 800.0, "combine": "hybrid", "shop": { "mode": "pct", "step": 0.08, "price_base": 70, "price_growth": 1.15, "curve": "geometric" } },
|
||||||
"cast_delay_mod": { "display_name": "施法延迟 cast_delay_mod", "base": 1.0, "soft": 0.1, "hard": 0.05, "combine": "inverse" },
|
"cast_delay_mod": { "display_name": "施法延迟 cast_delay_mod", "base": 1.0, "soft": 0.1, "hard": 0.05, "combine": "inverse", "shop": { "mode": "pct", "step": 0.10, "price_base": 90, "price_growth": 1.20, "curve": "geometric" } },
|
||||||
"hp_max": { "display_name": "最大生命 hp_max", "base": 100.0, "soft": 2000.0, "hard": 0.0, "combine": "hybrid" }
|
"hp_max": { "display_name": "最大生命 hp_max", "base": 100.0, "soft": 2000.0, "hard": 0.0, "combine": "hybrid", "shop": { "mode": "pct", "step": 0.10, "price_base": 60, "price_growth": 1.15, "curve": "geometric" } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ var _invuln_until_msec: int = 0 # < now 表示可受击;受击后设为 now
|
|||||||
# 属性定义与加成来源(非热路径)
|
# 属性定义与加成来源(非热路径)
|
||||||
var _attr_def: Dictionary = {} # attributes.json 全量定义,只读
|
var _attr_def: Dictionary = {} # attributes.json 全量定义,只读
|
||||||
var _modifiers: Array[Dictionary] = [] # [{attr_id, mode, value, source}]
|
var _modifiers: Array[Dictionary] = [] # [{attr_id, mode, value, source}]
|
||||||
|
var _attr_effective: Dictionary = {} # attr_id → 生效值;供冷路径(商店/UI/设计器)通用读取
|
||||||
|
|
||||||
# ── 魔力(MVP)─────────────────────────────────────────
|
# ── 魔力(MVP)─────────────────────────────────────────
|
||||||
var mana: float = 100.0
|
var mana: float = 100.0
|
||||||
@@ -233,6 +234,14 @@ func _recompute_attrs() -> void:
|
|||||||
cast_delay_mod = _compute_attr("cast_delay_mod")
|
cast_delay_mod = _compute_attr("cast_delay_mod")
|
||||||
hp_max = _compute_attr("hp_max")
|
hp_max = _compute_attr("hp_max")
|
||||||
hp = minf(hp, hp_max) # hp_max 下调(出售退款/换杖)时避免 hp > hp_max
|
hp = minf(hp, hp_max) # hp_max 下调(出售退款/换杖)时避免 hp > hp_max
|
||||||
|
# 冷路径通用视图:热路径(move_speed 每物理帧)仍走裸字段,此表只服务商店/UI 等按 id 取值的场景。
|
||||||
|
# 必须与裸字段在同一处更新——两者不同步会让商店显示的值与实际生效值不一致且无诊断。
|
||||||
|
_attr_effective = {
|
||||||
|
"cpu_limit": float(cpu_limit),
|
||||||
|
"move_speed": move_speed,
|
||||||
|
"cast_delay_mod": cast_delay_mod,
|
||||||
|
"hp_max": hp_max,
|
||||||
|
}
|
||||||
stats_changed.emit()
|
stats_changed.emit()
|
||||||
|
|
||||||
func _compute_attr(attr_id: String) -> float:
|
func _compute_attr(attr_id: String) -> float:
|
||||||
@@ -247,3 +256,10 @@ func _compute_attr(attr_id: String) -> float:
|
|||||||
assert(false, "PlayerStats: 缺少属性「%s」——生效值将为 0,运行时 push_error 不可见" % attr_id)
|
assert(false, "PlayerStats: 缺少属性「%s」——生效值将为 0,运行时 push_error 不可见" % attr_id)
|
||||||
return 0.0
|
return 0.0
|
||||||
return AttributeFormula.compute(float(d.get("base", 0.0)), _mods_for(attr_id), d)
|
return AttributeFormula.compute(float(d.get("base", 0.0)), _mods_for(attr_id), d)
|
||||||
|
|
||||||
|
## 按 id 取生效值(冷路径)。热路径请直接读裸字段(move_speed 等),本函数有字典查找开销。
|
||||||
|
func get_attr_value(attr_id: String) -> float:
|
||||||
|
if not _attr_effective.has(attr_id):
|
||||||
|
push_error("PlayerStats: 未知属性「%s」,无生效值" % attr_id)
|
||||||
|
return 0.0
|
||||||
|
return float(_attr_effective[attr_id])
|
||||||
|
|||||||
@@ -10,12 +10,20 @@ const SLOT_COUNT: int = 3
|
|||||||
const REROLL_BASE_COST: int = 20 # 第 1 次刷新费用
|
const REROLL_BASE_COST: int = 20 # 第 1 次刷新费用
|
||||||
const REROLL_STEP: int = 10 # 每次递增价格
|
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 current_slots: Array = [] # Array[SpellNode] (null 表示已售出)
|
||||||
var reroll_count: int = 0 # 本波已刷新次数(WAVE_COMPLETE 后重置)
|
var reroll_count: int = 0 # 本波已刷新次数(WAVE_COMPLETE 后重置)
|
||||||
var _shop_seed: int = 0 # 随机种(ADR-A2 P-S2-07)
|
var _shop_seed: int = 0 # 随机种(ADR-A2 P-S2-07)
|
||||||
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
|
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:
|
func _ready() -> void:
|
||||||
|
_load_attr_definitions()
|
||||||
EventBus.subscribe(EventID.WAVE_COMPLETE, _on_wave_complete)
|
EventBus.subscribe(EventID.WAVE_COMPLETE, _on_wave_complete)
|
||||||
|
|
||||||
func _on_wave_complete(_payload: Dictionary) -> void:
|
func _on_wave_complete(_payload: Dictionary) -> void:
|
||||||
@@ -86,7 +94,110 @@ func close_shop() -> void:
|
|||||||
func get_shop_seed() -> int:
|
func get_shop_seed() -> int:
|
||||||
return _shop_seed
|
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:
|
func reset() -> void:
|
||||||
current_slots.clear()
|
current_slots.clear()
|
||||||
reroll_count = 0
|
reroll_count = 0
|
||||||
_shop_seed = 0
|
_shop_seed = 0
|
||||||
|
_attr_purchases.clear()
|
||||||
|
_apply_attr_purchases() # 撤销 shop_c 加成(PlayerStats.reset_for_run 已清 _modifiers,此处保证独立调用时也正确)
|
||||||
|
|||||||
Reference in New Issue
Block a user