diff --git a/data/attributes.json b/data/attributes.json index 2e83751..470ed55 100644 --- a/data/attributes.json +++ b/data/attributes.json @@ -1,6 +1,6 @@ { - "cpu_limit": { "display_name": "运算力 cpu_limit", "base": 0.0, "soft": 20.0, "hard": 50.0, "combine": "add_int" }, - "move_speed": { "display_name": "移动速度 move_speed", "base": 200.0, "soft": 600.0, "hard": 800.0, "combine": "hybrid" }, - "cast_delay_mod": { "display_name": "施法延迟 cast_delay_mod", "base": 1.0, "soft": 0.1, "hard": 0.05, "combine": "inverse" }, - "hp_max": { "display_name": "最大生命 hp_max", "base": 100.0, "soft": 2000.0, "hard": 0.0, "combine": "hybrid" } + "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", "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", "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", "shop": { "mode": "pct", "step": 0.10, "price_base": 60, "price_growth": 1.15, "curve": "geometric" } } } diff --git a/scripts/autoloads/player_stats.gd b/scripts/autoloads/player_stats.gd index 695a577..b76643f 100644 --- a/scripts/autoloads/player_stats.gd +++ b/scripts/autoloads/player_stats.gd @@ -37,6 +37,7 @@ var _invuln_until_msec: int = 0 # < now 表示可受击;受击后设为 now # 属性定义与加成来源(非热路径) var _attr_def: Dictionary = {} # attributes.json 全量定义,只读 var _modifiers: Array[Dictionary] = [] # [{attr_id, mode, value, source}] +var _attr_effective: Dictionary = {} # attr_id → 生效值;供冷路径(商店/UI/设计器)通用读取 # ── 魔力(MVP)───────────────────────────────────────── var mana: float = 100.0 @@ -233,6 +234,14 @@ func _recompute_attrs() -> void: cast_delay_mod = _compute_attr("cast_delay_mod") hp_max = _compute_attr("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() 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) return 0.0 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]) diff --git a/scripts/autoloads/shop_manager.gd b/scripts/autoloads/shop_manager.gd index 3d34f27..d600604 100644 --- a/scripts/autoloads/shop_manager.gd +++ b/scripts/autoloads/shop_manager.gd @@ -10,12 +10,20 @@ 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: @@ -86,7 +94,110 @@ func close_shop() -> void: 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,此处保证独立调用时也正确)