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>
113 lines
6.1 KiB
GDScript
113 lines
6.1 KiB
GDScript
## 属性编辑器标签 — 玩家属性的 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 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]
|
||
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)
|