## 属性编辑器标签 — 玩家属性的 base / soft / hard / combine ## 数据:res://data/attributes.json(权威属性定义见 docs/design/numerical_design.md §1.1) @tool extends VBoxContainer const UI = preload("res://addons/game_designer/designer_ui.gd") const PATH = "res://data/attributes.json" # 显示与存储解耦:LABELS 仅供下拉显示,KEYS 才写进 JSON,二者 index 对齐 const COMBINE_KEYS = ["hybrid", "inverse", "add_int"] const COMBINE_LABELS = ["混合 hybrid", "反向 inverse", "整数加 add_int"] const ATTR_ORDER = ["cpu_limit", "move_speed", "cast_delay_mod", "hp_max"] const MODE_KEYS = ["flat", "pct"] const MODE_LABELS = ["固定量 flat", "百分比 pct"] const CURVE_KEYS = ["geometric", "linear", "flat"] const CURVE_LABELS = ["几何 geometric", "线性 linear", "固定 flat"] var _data: Dictionary = {} var _rows: Dictionary = {} # attr_id → {"base": SpinBox, "soft": SpinBox, "hard": SpinBox, "combine": OptionButton} var _shop_rows: Dictionary = {} # attr_id → {"mode","step","price_base","price_growth","curve"} 控件 var _status: Label var _loaded: bool = false # 加载失败时禁止写回,见 _save func _ready() -> void: add_child(UI.header("📊 属性编辑器 — 玩家属性 base / soft / hard / combine")) _load() var grid := GridContainer.new(); grid.columns = 5; add_child(grid) for h in ["属性", "基准 base", "软上限 soft", "硬上限 hard", "合成 combine"]: var l := Label.new(); l.text = h; l.modulate = Color(0.7, 0.8, 1.0) grid.add_child(l) for attr_id in ATTR_ORDER: # _load 已保证:_loaded 时每个条目都是 Dictionary,否则 _data 为空 var d: Dictionary = _data.get(attr_id, {}) grid.add_child(UI.cell_label(String(d.get("display_name", attr_id)))) # 四个属性按定义均非负,故 min = 0;且 min 取 0 可避开 Range 的抵消误差(见 _clean) var sp_base := UI.spin(0.0, 99999.0, 0.01, float(d.get("base", 0.0))) var sp_soft := UI.spin(0.0, 99999.0, 0.01, float(d.get("soft", 0.0))) var sp_hard := UI.spin(0.0, 99999.0, 0.01, float(d.get("hard", 0.0))) var combine_key := String(d.get("combine", "hybrid")) var idx: int = COMBINE_KEYS.find(combine_key) # 本页凡是「静默改写权威数据」的路径都必须留下诊断,一条都不能漏—— # 保存会把改写结果写回 attributes.json,而下游 PlayerStats 只会照单全收 if d.is_empty(): # 条目缺失时占位的 0 一旦被保存,键就「存在」了, # player_stats.gd 的「缺少属性」报错从此不再触发,hp_max=0 静默进游戏 push_warning("attribute_tab: %s 缺少属性「%s」,本页以 0 值占位,保存会把 0 写进文件" % [PATH, attr_id]) else: if idx < 0: # 不硬拒(打字错误不该让整页打不开),但必须留下诊断:保存会把它改写成 hybrid push_warning("attribute_tab: 「%s」的 combine「%s」无法识别,下拉已回落到 hybrid,保存将覆盖原值" % [attr_id, combine_key]) for f in ["base", "soft", "hard"]: if d.has(f) and float(d[f]) < 0.0: push_warning("attribute_tab: 「%s」的 %s = %s 为负,输入框下限 0 已将其钳制,保存会把 0 写进文件" % [attr_id, f, str(d[f])]) var op := UI.opt(COMBINE_LABELS, idx if idx >= 0 else 0) grid.add_child(sp_base); grid.add_child(sp_soft); grid.add_child(sp_hard); grid.add_child(op) _rows[attr_id] = {"base": sp_base, "soft": sp_soft, "hard": sp_hard, "combine": op} add_child(HSeparator.new()) add_child(UI.header("🛒 货架 C — 可售配置(无 shop 段 = 不可购买)")) var g2 := GridContainer.new(); g2.columns = 6; add_child(g2) for h2 in ["属性", "增量类型 mode", "每级 step", "首价 price_base", "涨幅 growth", "曲线 curve"]: var l2 := Label.new(); l2.text = h2; l2.modulate = Color(0.7, 0.8, 1.0) g2.add_child(l2) for attr_id2 in ATTR_ORDER: var sp: Dictionary = _data.get(attr_id2, {}).get("shop", {}) g2.add_child(UI.cell_label(String(_data.get(attr_id2, {}).get("display_name", attr_id2)))) var mi: int = MODE_KEYS.find(String(sp.get("mode", "flat"))) var op_mode := UI.opt(MODE_LABELS, mi if mi >= 0 else 0) var sp_step := UI.spin(0.0, 99999.0, 0.01, float(sp.get("step", 0.0))) var sp_pbase := UI.spin(0.0, 99999.0, 1.0, float(sp.get("price_base", 0.0))) var sp_grow := UI.spin(0.0, 99999.0, 0.01, float(sp.get("price_growth", 1.0))) var ci: int = CURVE_KEYS.find(String(sp.get("curve", "geometric"))) var op_curve := UI.opt(CURVE_LABELS, ci if ci >= 0 else 0) g2.add_child(op_mode); g2.add_child(sp_step); g2.add_child(sp_pbase) g2.add_child(sp_grow); g2.add_child(op_curve) _shop_rows[attr_id2] = {"mode": op_mode, "step": sp_step, "price_base": sp_pbase, "price_growth": sp_grow, "curve": op_curve} add_child(HSeparator.new()) var tip := Label.new() tip.text = "hard=0 在 hybrid / add_int 下表示不钳制;inverse 的 hard 是「下限」,填 0 即钳到 0(与 hybrid 相反);add_int 拒绝 pct 加成;soft 本期不参与计算(留给货架 C)。cpu_limit 的 mode 必须是 flat(add_int 拒绝 pct);shop 段缺失即该属性不可购买。" # dock 的 ScrollContainer 禁用横向滚动,不换行的话这句会被右侧裁掉(正是最需要看到的半句) tip.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART tip.modulate = Color(0.7, 0.7, 0.7) add_child(tip) add_child(UI.btn("💾 保存 attributes.json", _save)) _status = UI.status_label(); add_child(_status) ## 加载失败必须留痕并禁止写回:本页的零值载荷(move_speed.base = 0 等)是合法 JSON, ## PlayerStats 会照单全收,游戏表现为玩家不能移动 / 瞬间死亡且毫无诊断。 func _load() -> void: var d = UI.load_json(PATH) _loaded = d is Dictionary if not _loaded: push_error("attribute_tab: 无法加载 %s(缺失或格式错误)" % PATH) _data = {} return # 逐条守卫,同 player_stats.gd 的既有做法:畸形条目(如 "hp_max": 5)会让下面的 # Dictionary 赋值硬崩、dock 停在半构建状态;整个文件拒绝加载并禁止写回 for k in d: if not (d[k] is Dictionary): push_error("attribute_tab: %s 的「%s」应为对象,整个文件已拒绝加载" % [PATH, k]) _loaded = false _data = {} return _data = d ## Range 用 round((v - min) / step) * step + min 吸附取值。min = -99999 与 0.01 的 step ## 相差七个数量级,此式在此发生抵消误差(0.01 → 0.00999999999476),直接写回会把噪声 ## 固化进 JSON。本页 min = 0.0 时 12 个值原本就逐位精确,本函数当前不改变落盘结果 ##(唯一副作用:0.1 在内存里低 1 ULP,stringify 仍输出 0.1)。 ## 勿因此删除 —— 一旦某属性需要负值 / 宽区间而放宽 min,抵消误差立即回来。 static func _clean(v: float) -> float: return snappedf(v, 0.000001) ## 表单 → 字典(合并式:以 _data 为基底保留未知属性条目,以及条目内未列入表单的键; ## 顶层标量键不在此列——_load 已因它们整体拒绝加载) func _collect() -> Dictionary: var out: Dictionary = _data.duplicate(true) for attr_id in ATTR_ORDER: var r: Dictionary = _rows.get(attr_id, {}) if r.is_empty(): continue var entry: Dictionary = out.get(attr_id, {}).duplicate(true) entry["base"] = _clean(r["base"].value) entry["soft"] = _clean(r["soft"].value) entry["hard"] = _clean(r["hard"].value) # 存 key,不存双语显示串;selected 恒为 0..2(UI.opt 构造时已 clampi,本页此后不再赋值) entry["combine"] = COMBINE_KEYS[r["combine"].selected] var r2: Dictionary = _shop_rows.get(attr_id, {}) # 「无 shop 段即不可售」是货架 C 的核心不变量(ShopManager.get_sellable_attrs 靠它筛选 # 可售集合)。_shop_rows 对 ATTR_ORDER 里每个属性都无条件建了控件行(哪怕原本没有 shop # 段,行以 0 值占位),所以不能只凭 r2 非空就写回;必须再确认 _data 里原本就有这一段, # 否则会把 price_base=0 的空段写进 JSON——PriceFormula 把 0 价地板钳到 1G, # 一个零效果属性从此以 1G 可无限购买,直接违反上述不变量。 var had_shop: bool = _data.get(attr_id, {}).get("shop", null) is Dictionary if not r2.is_empty() and had_shop: var shop_entry: Dictionary = entry.get("shop", {}).duplicate(true) shop_entry["mode"] = MODE_KEYS[r2["mode"].selected] # 存 key 不存显示串 shop_entry["step"] = _clean(float(r2["step"].value)) # 注意:不用 int()——JSON.parse_string 对所有 JSON 数字(含无小数点的字面量, # 如源文件的 "price_base": 120)一律解析为 float,若此处存 int 则往返比较时 # str(120) != str(120.0) 恒假失败,与 price_formula.gd 的 float() 读取方式一致 shop_entry["price_base"] = _clean(float(r2["price_base"].value)) shop_entry["price_growth"] = _clean(float(r2["price_growth"].value)) shop_entry["curve"] = CURVE_KEYS[r2["curve"].selected] entry["shop"] = shop_entry out[attr_id] = entry return out func _save() -> void: if not _loaded: UI.set_status(_status, "✗ 定义未加载,拒绝写回", true) return if UI.save_json(PATH, _collect()): UI.set_status(_status, "💾 已保存(重启 F5 生效)") else: UI.set_status(_status, "✗ 保存失败", true)