Files
spellforge/addons/game_designer/attribute_tab.gd
T
joywayerandClaude Opus 5 7884e43147 fix(attr): 补齐属性页最后两条静默改写数据的诊断,并订正两处注释
1. 条目缺失时无任何诊断(上一轮 `and not d.is_empty()` 引入的回归)。
   attributes.json 格式良好但少了 hp_max 时,_loaded 仍为 true、保存照常,
   _collect 写出 {"base":0,"soft":0,"hard":0,"combine":"hybrid"}。更糟的是这个键
   从此「存在」,player_stats.gd 的「缺少属性」报错也不再触发,hp_max=0 完全静默
   进游戏;而 hard=0 在 hybrid 下意味着不钳制,连兜底都没有,玩家瞬死。
   改为 if d.is_empty() 单独 push_warning,与 combine 那条对称。

2. min=0.0 引入的负值静默钳制同样无诊断("move_speed":{"base":-50} → 写回 0)。
   运行时本就把生效值钳到 ≥0,但静默改写权威数据正是 combine 警告要防的事,
   并入同一警告块保持处理对称。

3. _clean 的注释末句由描述改为禁令。原文「当前 12 个值并不依赖本函数」是事实,
   但读起来像绿灯,而删掉它一直无害——直到有人放宽 min,也就是它存在的唯一场景。

4. _collect 的注释夸大了:未知键只有是对象时才存活,顶层标量键会让 _load 整体
   拒绝加载。改为「保留未知属性条目」。

实测:缺 hp_max 与 base=-1234.5 两条警告均如实发出;未改动的 attributes.json
连建三次零警告零报错(误报比不报更糟,已专门验证)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:35:12 +08:00

113 lines
6.1 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"]
var _data: Dictionary = {}
var _rows: Dictionary = {} # attr_id → {"base": SpinBox, "soft": SpinBox, "hard": SpinBox, "combine": OptionButton}
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())
var tip := Label.new()
tip.text = "hard=0 在 hybrid / add_int 下表示不钳制;inverse 的 hard 是「下限」,填 0 即钳到 0(与 hybrid 相反);add_int 拒绝 pct 加成;soft 本期不参与计算(留给货架 C)。"
# 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]
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)