Files
spellforge/addons/game_designer/attribute_tab.gd
T
joywayerandClaude Opus 5 d69389e09d feat(shop): 设计器「属性」页扩展 shop 段五字段
mode/curve 下拉存 key 不存双语显示串;UI.spin 的 min 取 0 以免 Range 的
抵消误差把浮点噪声写进权威数据文件(E3-① 实测);price_base 存 float
而非 int——JSON.parse_string 对所有 JSON 数字一律解析为 float,存 int
会使往返逐位比较恒假失败,且与 price_formula.gd 的 float() 读取一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:56:55 +08:00

150 lines
8.6 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 属性编辑器标签 — 玩家属性的 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 必须是 flatadd_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 ULPstringify 仍输出 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..2UI.opt 构造时已 clampi,本页此后不再赋值)
entry["combine"] = COMBINE_KEYS[r["combine"].selected]
var r2: Dictionary = _shop_rows.get(attr_id, {})
if not r2.is_empty():
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)