Files
gplan-2/game/presentation/views/level_map_screen.gd
T
DKChan ed8e0f9f3d iter34 卡④ T1-T3: 信息迷雾类型可见性——层距 N+Boss/候选豁免+单圈回雾(N 读表)+呈现显隐决策面+画布大图断言与探针常数同源
- level_table.gd: info_visible_layers 字段(int≥1)+validate 越域 domain 错;level.tres 挂 2
- level_runner.gd: type_visible_ids() 纯函数(入口 BFS 层差 ≤N ∪ Boss ∪ 候选,锚=当前节点层/入口停点 0;visited 不豁免)+_bfs_layers()
- level_map_screen.gd: node_display() 静态决策面+display_probe()+_draw_graph 图标/文字/Boss 描边三处改读决策面(结构通道零改动)
- 新单测 level_map_type_vis_test(26 断言)/level_map_info_fog_test(19 断言);run_all 注册两行
- smoke_all ⑬ 真图 L2 入口停点可见性组(+5 断言);ui_input_probe ⑬ 常数锚同源化+⑱ 雾不扩可点域

轻闸读数:冒烟 1211 项 0 失败(主区基线 1206,+5=新增组)/probe_gate --click 全绿
2026-10-22 05:52:11 +08:00

613 lines
28 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.
## VERTICAL SLICE - NOT FOR PRODUCTION
## 关卡画面·独立屏:全屏不透明独占呈现(激活期战斗屏宿主由组合根整体隐藏),节点图选路呈现。
## 前进唯一通道=图上点候选节点(route_chosen 外发)——零自动推进;放弃入口可见性归组合根(仅冒险模式开)。
class_name LevelMapScreen
extends Control
signal route_chosen(node_id: int)
signal abandon_requested
const COL_ROOM_DONE := UiKit.GOLD_DONE # 已到——暗金 #68562C(GOLD_DIM 对双色墨均 <4.5:1,对比硬指标逼守此值;UiKit.GOLD_DIM 他用不动)
const COL_ROOM_CURRENT := UiKit.GOLD # 当前——奖励金
const COL_ROOM_FUTURE := UiKit.DARK_UPCOMING # 未达——暗
const COL_PASSAGE := UiKit.STONE # 通道——石灰
const COL_PASSAGE_LIT := UiKit.CANDLE # 候选通道/候选描边——烛光黄(金色定岗只留当前位)
const COL_BOSS_BORDER := UiKit.BOSS_PURPLE # Boss 边框——紫赤(色相独占本色)
const COL_ABANDON := UiKit.RED_MINE # 破坏性动作红(危险红族)
const NODE_LABELS := {"battle": "战", "reward": "赏", "rest": "疗", "boss": "王", "shop": "商"} # 贴图缺位字形回退用——贴图在位=字形退役惯例
const NODE_TYPE_NAMES := {"battle": "战斗", "reward": "奖励", "rest": "休息", "boss": "Boss", "shop": "商店"} # 节点下方类型字(demo ④——三通道可读性;当前位显示「当前位置」)
const TEX_NODE_ICONS := {"battle": UiKit.TEX_NODE_BATTLE, "reward": UiKit.TEX_NODE_REWARD,
"rest": UiKit.TEX_NODE_REST, "boss": UiKit.TEX_NODE_BOSS, "shop": UiKit.TEX_NODE_SHOP}
const NODE_ICON_PX := 20.0 # 节点图标边长(20px 烘焙 1:1 消费零缩放)
const TITLE_ENTRY := "出发——选择首个节点" # 入口停点(布局决策 1)
const TITLE_SINGLE := "继续前进" # 单链停点
const TITLE_FORK := "选择下一节点" # 岔路停点
const NODE_HALF := Vector2(36.5, 25) # 节点方块半宽高(73×50 = 52×36 的 1.4×;层距/布局公式零改)
const LAYER_BASE_X := 70 # 首层节点原点 x(画布拟合与布局公式共用)
const LAYER_STEP_X := 122 # 层间横向步距(同上)
const SLOT_BASE_Y := 52 # 槽位原点 y(同上)
const SLOT_STEP_Y := 58 # 同层槽位纵向步距(同上)
const HIT_PAD := 6.0 # 图上命中外扩(指宽容差)
const CONFIRM_WINDOW_S := 3.0 # 放弃确认态窗口(秒——超时回落)
const COL_HP_BAR_BACK := UiKit.HP_BAR_BACK # 迷你血条深底(对暗面板可辨;值单点收 UiKit)
var _runner = null
var _title: Label
var _canvas: Control
var _level_label: Label
var _gp_value: Label
var _hp_value: Label
var _hp_bar: Control
var _attr_value: Label
var _bonus_row: HBoxContainer
var _bonus_value: Label
var _feat_value: Label # 特性持有行(组合根投喂逐件文案)
var _cand_ids: Array[int] = [] # 本停点候选 id 集(图上命中合法集)
var _content_size := Vector2(620, 240) # 图内容包络(open_choices 按图拟合——画布 min 尺寸随它)
var _hover_id := -1 # 悬停预承诺候选(-1 = 无)
var abandon_btn: Button # 放弃入口(可见性归组合根 set_abandon_available——只在冒险模式开)
var status_badge: Button # 状态面板入口角标(顶栏 GP 徽右侧;连接归组合根)
var _abandon_timer := ArmedTimer.new() # 放弃确认窗(_process 帧驱动倒计时机制收口;窗口=CONFIRM_WINDOW_S)
var _node_icons := {} # 节点图标贴图(load_tex 守卫——缺位 null → 字形回退,冒烟/无素材检出安全)
var _noise_rect: TextureRect # 屏底石纹(换层色板渐变承载面——null = 纯色回退无挂点)
var _tint_level := 0 # 当前已应用 tint 的层号(0 = 未应用)
func _init() -> void:
_abandon_timer.expired.connect(_reset_abandon_confirm)
# 节点图标贴图装载(load_tex 守卫,缺位回退现行字形)
for t in TEX_NODE_ICONS:
_node_icons[t] = UiKit.load_tex(TEX_NODE_ICONS[t])
# 独立屏底(spec Layout——demo #v-map 暖棕竖向
# 渐变(#4A3B2A 0%→#3A2E22 40%)+石纹弱质感 0.14+暗角聚焦;底纹缺位回退纯色)
var base := UiKit.gradient_base(UiKit.BG_MAP_TOP, UiKit.BG_ROOT)
add_child(base)
var tex := UiKit.backdrop_tile()
if tex != null:
base.add_child(tex)
_noise_rect = tex # 世界侧 tint 承载面(换层色板渐变挂点——§2.7 切片档)
base.add_child(UiKit.vignette_rect())
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
base.add_child(center)
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 10)
center.add_child(box)
# 顶栏语境(spec Layout:左关卡名 / 右 GP 常驻徽章——直角细线数据框;
# 对齐 demo ④:关卡名入框板+GP 徽章=烛光胶囊(同商店GP框)+
# 左右两极排布)
var top := HBoxContainer.new()
top.add_theme_constant_override("separation", 12)
top.size_flags_horizontal = Control.SIZE_EXPAND_FILL
box.add_child(top)
var lvl_plate := PanelContainer.new()
lvl_plate.add_theme_stylebox_override("panel",
UiKit.panel_flat(UiKit.DARK_UPCOMING, UiKit.PANEL_BORDER, 0, 1, Vector4(12, 3, 12, 3)))
top.add_child(lvl_plate)
_level_label = UiKit.label("",UiKit.FS_BODY, true, UiKit.WARM)
lvl_plate.add_child(_level_label)
var top_sp := Control.new()
top_sp.size_flags_horizontal = Control.SIZE_EXPAND_FILL
top.add_child(top_sp)
var gp_box := PanelContainer.new()
gp_box.add_theme_stylebox_override("panel",
UiKit.panel_flat(UiKit.CANDLE, UiKit.CAMP_HEADER_BAND,UiKit.RADIUS_OBJECT, 1, Vector4(12, 4, 12, 4)))
top.add_child(gp_box)
var gp_row := HBoxContainer.new()
gp_row.add_theme_constant_override("separation", 6)
gp_box.add_child(gp_row)
_gp_value = UiKit.label("",UiKit.FS_BODY, true, UiKit.INK_ON_GOLD)
gp_row.add_child(_gp_value)
# 状态面板入口角标(顶栏常驻;本屏只留状态——调试角标归全局浮层。
# 置灰守卫归组合根 _sync_status_entry(其他模态开着=不可点;本屏自身的登记开态不计——has_open_excluding);
# 信号连接归组合根(视图零连接))
status_badge = UiKit.button("状态", 0.0)
status_badge.add_theme_font_size_override("font_size", UiKit.FS_HINT)
status_badge.tooltip_text = "查看钥石与永久强化进度(只读)"
status_badge.size_flags_vertical = Control.SIZE_SHRINK_CENTER
top.add_child(status_badge)
_title = UiKit.label(TITLE_FORK,UiKit.FS_SUB, true, UiKit.WARM)
_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_title)
# 节点图画布(图上点击 = 唯一提交通道——布局决策 2;mouse_filter STOP 接悬停/点击)
_canvas = Control.new()
_canvas.custom_minimum_size = Vector2(620, 240)
_canvas.mouse_filter = Control.MOUSE_FILTER_STOP
_canvas.draw.connect(_draw_graph.bind(_canvas))
_canvas.gui_input.connect(_on_canvas_input)
box.add_child(_canvas)
# 底部状态栏(合并单条:亮米面板一站读齐=头像+生命+属性+本关加成/流派件——demo ④;
# 道具槽不摆=道具读数无关卡画面成文出口(读快照私域越权);
# _hp_value/_attr_value/_bonus_value/_feat_value 投喂键全部保留)
# 底栏定高纪律(对齐 demo——单条 ~85px 紧凑亮条;任何子件不再能把栏撑爆:
# 特性持有行限宽截断+tooltip 全文)
var bottom := PanelContainer.new()
bottom.custom_minimum_size = Vector2(0, 85)
bottom.size_flags_vertical = Control.SIZE_SHRINK_END
bottom.add_theme_stylebox_override("panel",
UiKit.panel_flat(UiKit.MAP_BAR_PLATE, UiKit.CAMP_HEADER_BAND, 0, 2, Vector4(12, 6, 12, 6)))
box.add_child(bottom)
var strip := HBoxContainer.new()
strip.add_theme_constant_override("separation", 18)
bottom.add_child(strip)
var face := TextureRect.new()
var face_tex := UiKit.load_tex(UiKit.TEX_HERO_PORTRAIT)
# 定高 48px=1×、宽随源图比自适应(源 46×48 → 46×48;纵横异倍消除)
var face_h := 48.0
var face_w := face_h
if face_tex != null:
face.texture = face_tex
if face_tex.get_height() > 0:
face_w = floorf(face_h * face_tex.get_width() / float(face_tex.get_height()))
face.custom_minimum_size = Vector2(face_w, face_h)
face.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
face.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
face.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
strip.add_child(face)
var life_box := VBoxContainer.new()
strip.add_child(life_box)
life_box.add_child(UiKit.label("生命",UiKit.FS_CAPTION, false, UiKit.INK_HINT))
_hp_value = UiKit.label("",UiKit.FS_BODY, true, UiKit.INK_TITLE)
life_box.add_child(_hp_value)
_hp_bar = Control.new()
_hp_bar.custom_minimum_size = Vector2(130, 7)
_hp_bar.draw.connect(_draw_hp_bar.bind(_hp_bar))
life_box.add_child(_hp_bar)
strip.add_child(_vdiv())
var attr_box := VBoxContainer.new()
strip.add_child(attr_box)
attr_box.add_child(UiKit.label("属性",UiKit.FS_CAPTION, false, UiKit.INK_HINT))
_attr_value = UiKit.label("",UiKit.FS_BODY, true, UiKit.INK_TITLE)
attr_box.add_child(_attr_value)
strip.add_child(_vdiv())
# 本关加成框(零加成隐藏——spec 布局决策 4)+ 流派件持有行(组合根投喂)
_bonus_row = HBoxContainer.new()
strip.add_child(_bonus_row)
var bonus_box := VBoxContainer.new()
_bonus_row.add_child(bonus_box)
var bonus_inner := VBoxContainer.new()
bonus_box.add_child(bonus_inner)
var bonus_cap := UiKit.label("特性",UiKit.FS_CAPTION, false, UiKit.INK_HINT) # 深墨(亮板可读)
bonus_inner.add_child(bonus_cap)
_bonus_value = UiKit.label("",UiKit.FS_BODY, true, UiKit.INK_TITLE) # 淡墨→深墨(同上)
bonus_inner.add_child(_bonus_value)
# 特性持有行(组合根投喂)
# 限宽单行截断+tooltip 全文;
# 墨色 INK_HINT(暖白系对亮板 ≈1:1 不可读——§7.2)
_feat_value = UiKit.label("",UiKit.FS_HINT, false, UiKit.INK_HINT)
_feat_value.custom_minimum_size = Vector2(240, 0)
_feat_value.clip_text = true
_feat_value.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
bonus_box.add_child(_feat_value)
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
strip.add_child(spacer)
abandon_btn = UiKit.danger_button("放弃", "", 90.0) # 小号危险钮 ~90×38
abandon_btn.pressed.connect(_on_abandon_pressed)
abandon_btn.visible = false
strip.add_child(abandon_btn)
visible = false
# 竖分隔线(底栏分组线——亮面板上低对比细线)
static func _vdiv() -> ColorRect:
var v := ColorRect.new()
v.color = UiKit.MAP_VDIV
v.custom_minimum_size = Vector2(1, 44)
return v
func _draw_hp_bar(bar: Control) -> void:
# 迷你血条(深底 UiKit.HP_BAR_BACK #28221C + 小人蓝填充——与战斗站桩血条同语言,
# 「蓝只说己方」§4.2)。比例 = hero_hp / effective_max_hp
if _runner == null:
return
var max_hp := maxi(1, _runner.effective_max_hp())
var ratio := clampf(float(_runner.hero_hp) / float(max_hp), 0.0, 1.0)
var full := Rect2(Vector2.ZERO, bar.size)
bar.draw_rect(full, COL_HP_BAR_BACK)
if ratio > 0.0:
bar.draw_rect(Rect2(Vector2.ZERO, Vector2(bar.size.x * ratio, bar.size.y)), UiKit.HERO_BLUE)
# 放弃入口可见性(组合根在冒险启动时开、默认关——语义对象 = 整局冒险)
# 特性持有投影(组合根投喂逐件文案;空文案=行隐藏)
func set_feat_holdings(text: String) -> void:
_feat_value.text = text
_feat_value.tooltip_text = text # 截断全量兜底(限宽单行省略——悬停看全文)
func set_abandon_available(v: bool) -> void:
abandon_btn.visible = v
# 放弃二次确认(布局决策 3):首击转确认态(文案+危险红脉冲约 3 秒超时回落),二击才提交。
# 单击不提交——ui_probe 战斗中 pressed.emit() 单击路径保持「未受理」语义(战斗中拒绝归组合根)。
func _on_abandon_pressed() -> void:
if not _abandon_timer.is_armed():
_abandon_timer.arm(CONFIRM_WINDOW_S)
abandon_btn.text = "再点一次确认放弃——不可逆"
if ArmedTimer.motion_still():
abandon_btn.modulate = Color(1, 1, 1, 0.75) # 减动效静态基色(脉冲层不再驱动)
return
_reset_abandon_confirm()
abandon_requested.emit()
func _reset_abandon_confirm() -> void:
_abandon_timer.disarm()
abandon_btn.modulate = Color(1, 1, 1, 1)
abandon_btn.text = "放弃" # 文案收敛 level-map 4 字上限(确认态转义面见 _on_abandon_pressed)
func _process(delta: float) -> void:
if not _abandon_timer.is_armed():
return
_abandon_timer.tick(delta)
if not _abandon_timer.is_armed():
return # 窗口到点回落(expired 信号已驱动 _reset_abandon_confirm)
# 危险红边框脉冲(单段呼吸约 1Hz——闪烁基线 ≤3 次/秒内;减动效 = 静态半透不闪——档判据单点 motion_still)
if ArmedTimer.motion_still():
abandon_btn.modulate = Color(1, 1, 1, 0.75)
else:
var k := 0.5 + 0.5 * sin(_abandon_timer.elapsed * TAU)
abandon_btn.modulate = Color(1, 1, 1, 0.65 + 0.35 * k)
# 打开选路口(cands = 候选节点引用数组;runner 只读用于整图渲染与状态栏刷新)
func open_choices(cands: Array, runner: LevelRunner) -> void:
var was_visible := visible # 进入转场判据(就地刷新/模态栈恢复不重放淡入)
_runner = runner
_hover_id = -1
_reset_abandon_confirm()
var visited: Array[int] = []
if runner != null:
visited = runner.visited_ids()
if cands.size() > 1:
_title.text = TITLE_FORK
elif visited.is_empty():
_title.text = TITLE_ENTRY # 入口停点(尚未走出第一步)
else:
_title.text = TITLE_SINGLE
_cand_ids = []
for cand in cands:
_cand_ids.append(_id_of(cand))
_fit_canvas(_runner.nodes())
_refresh_status()
_apply_level_tint(int(_runner.level_row.get("level_index", 0)))
visible = true
if not was_visible:
# 独立屏淡入(spec Transitions:约 200ms;alpha 淡入非明度变化——art-bible §2 明度恒定)
modulate.a = 0.0
var ftw := create_tween()
ftw.tween_property(self, "modulate:a", 1.0, UiKit.motion(0.2))
# 画布随图自适应(level-map.md Layout:画布宽随层数、高随同层最大节点数——公式与 _layout 同源;
# 固定 620×240 画布下深层节点(Boss 内容 x=802)落在 gui_input
# 命中区外——绘制无裁剪看得见、命中收不到事件。单链关卡层深=节点序,必须先拟合再命中。
func _fit_canvas(nodes: Array) -> void:
if nodes.is_empty():
return
var layer := {}
var slot := {}
_assign_layers(nodes, layer, slot)
var maxl := 0
var maxslot := 0
for k in layer.keys():
maxl = maxi(maxl, int(layer[k]))
for k in slot.keys():
maxslot = maxi(maxslot, int(slot[k]))
_content_size = Vector2(LAYER_BASE_X + maxl * LAYER_STEP_X + NODE_HALF.x + 8.0,
SLOT_BASE_Y + maxslot * SLOT_STEP_Y + NODE_HALF.y + 24.0) # +24 = 类型文字标签行
_content_size = _content_size.max(Vector2(620, 256)) # 下限(抬高容纳标签行)
_canvas.custom_minimum_size = _content_size
# 换层色板渐变挂点(art-bible §2.7 下探过渡切片档——关卡结算确认→下一关→独立屏淡入链路上的
# 色相层进预告:屏底世界侧随层进换挡,信息层恒定;tint 明度带归一≈1 只动色相/饱和)。
# tint 候选方向 = art-bible §6.3 三关图类型色板方向(1 dungeon 暖石基准/2 underdark 冷蓝灰/3 deep 灰黑金属),
# 只落挂点(level-map.md States 换层进入行承载)。
const BOARD_TINTS := {1: Color(1, 1, 1), 2: Color(0.84, 0.99, 1.28), 3: Color(0.97, 0.99, 1.09)}
func _apply_level_tint(level_index: int) -> void:
if _noise_rect == null or level_index == _tint_level:
return
_tint_level = level_index
var target: Color = BOARD_TINTS.get(level_index, Color(1, 1, 1))
var tw := create_tween()
tw.tween_property(_noise_rect, "modulate",
Color(target.r, target.g, target.b, UiKit.BACKDROP_TILE_ALPHA), UiKit.motion(0.45)) # 0.14 弱质感档随 tint 保持
# 状态栏刷新(命令式纪律——组合根打开本屏/兑付确认回执后重查;零推送信号订阅)
func _refresh_status() -> void:
if _runner == null:
return
_level_label.text = String(_runner.level_row.get("display_name", ""))
_gp_value.text = "◈ %d" % int(_runner.gp)
var max_hp: int = _runner.effective_max_hp()
_hp_value.text = "HP %d/%d" % [int(_runner.hero_hp), int(max_hp)]
_hp_bar.queue_redraw()
var atk_bonus := int(_runner.run_ledger.bonus.get("atk", 0)) # 加算区住 RunLedger
var ac_bonus := int(_runner.run_ledger.bonus.get("armor_class", 0))
var atk_txt := "%d" % _runner.effective_atk()
if atk_bonus > 0:
atk_txt += " +%d" % atk_bonus # 加成角标(ASCII 加号——防像素字体缺 ⁺ 字形出豆腐块)
var ac_txt := "%d" % _runner.effective_ac()
if ac_bonus > 0:
ac_txt += " +%d" % ac_bonus
_attr_value.text = "攻击 %s · 护甲 AC %s" % [atk_txt, ac_txt]
var hp_bonus := int(_runner.run_ledger.bonus.get("max_hp", 0))
var parts: Array[String] = []
if atk_bonus > 0:
parts.append("攻 +%d" % atk_bonus)
if hp_bonus > 0:
parts.append("上限 +%d" % hp_bonus)
if ac_bonus > 0:
parts.append("AC +%d" % ac_bonus)
_bonus_value.text = " · ".join(parts)
# 特性持有行可见性(与加成行并框——任一非空整框显示;文案归组合根 set_feat_holdings)
_bonus_row.visible = not parts.is_empty() or _feat_value.text != ""
# ---- 图上命中(布局决策 2——唯一提交通道;探针 press_candidate 同路径) ----
func _on_canvas_input(ev: InputEvent) -> void:
if _runner == null or not visible:
return
if ev is InputEventMouseMotion:
var nid := _candidate_at(ev.position) # gui_input 事件坐标 = 接收控件局部坐标(push_input 同成立)
if nid != _hover_id:
_hover_id = nid
_canvas.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if nid >= 0 else Control.CURSOR_ARROW
_canvas.queue_redraw()
elif ev is InputEventMouseButton and ev.button_index == MOUSE_BUTTON_LEFT and ev.pressed:
var nid2 := _candidate_at(ev.position)
if nid2 >= 0:
_submit_choice(nid2)
# 候选命中:位置 = 布局公式(与 _draw_graph 同源),矩形 = 节点方块 + 外扩容差;非候选格不响应
func _candidate_at(local_pos: Vector2) -> int:
if _runner == null:
return -1
var nodes: Array[Variant] = _runner.nodes()
var pos := _layout(nodes, _canvas)
for nid in _cand_ids:
var node := _node_by_id(nid, nodes)
if node.is_empty():
continue
var np: Vector2 = pos[node]
var rect := Rect2(np - NODE_HALF - Vector2(HIT_PAD, HIT_PAD), (NODE_HALF + Vector2(HIT_PAD, HIT_PAD)) * 2.0)
if rect.has_point(local_pos):
return nid
return -1
func _submit_choice(nid: int) -> void:
# 独立屏分支:战斗/Boss 节点 = 出屏切回战斗屏;奖励/恢复/商店节点 =
# 本屏保持激活——兑付覆盖层由节点事件在本屏域内弹出(模态栈遮挡恢复语义复用),兑付确认后回图刷新
var node := _node_by_id(nid, _runner.nodes())
var t := String(node.get("type", ""))
if t != "reward" and t != "rest" and t != "shop":
visible = false
route_chosen.emit(nid)
# 探针复用:按候选序号走图上命中同路径(候选节点中心坐标 → 命中检测 → 提交——布局决策 2 探针面改造)
func press_candidate(index: int) -> void:
if index < 0 or index >= _cand_ids.size() or _runner == null:
return
var nodes: Array[Variant] = _runner.nodes()
var node := _node_by_id(int(_cand_ids[index]), nodes)
if node.is_empty():
return
var pos: Vector2 = _layout(nodes, _canvas)[node]
var nid := _candidate_at(pos) # 节点中心必命中自身(命中面含节点方块)
if nid >= 0:
_submit_choice(nid)
func _id_of(node: Dictionary) -> int:
# 编号显示经行进器读面(编号空间解析单一出口——单链 = 下标 / 图 = node_depth)
return _runner.node_id_of(node)
# ---- 节点呈现决策面(信息迷雾 v2.1:类型仅在可见集内出示,Boss 标记不入雾)----
# 纯函数、零内容读取:只消费类型与状态(节点内详情=打谁/给什么,图上从不显示)。
static func node_display(node: Dictionary, nid: int, type_vis: Array,
is_current: bool, is_candidate: bool) -> Dictionary:
var t := String(node.get("type", ""))
var is_boss: bool = t == "boss"
var type_shown: bool = is_boss or type_vis.has(nid)
var label_text := String(NODE_TYPE_NAMES.get(t, t))
if is_current:
label_text = "当前位置"
elif is_candidate:
label_text += " · 可选"
return {
"draw_icon": type_shown,
"draw_label": type_shown,
"label_text": label_text,
"boss_border": is_boss,
}
# ---- 整图自绘(程序化占位——分层布局:层 = 入口 BFS 深度,同层垂直排布)----
func _draw_graph(canvas: Control) -> void:
if _runner == null or not visible:
return
var nodes: Array[Variant] = _runner.nodes()
var pos := _layout(nodes, canvas)
var current_id: int = _runner.current_node_id()
# 当前节点 id(单链=下标 / 图=编号)——visited_ids() 已按编号空间解析
var visited: Array[Variant] = _runner.visited_ids()
var cand_ids: Array[Variant] = _runner.next_candidate_ids()
var type_vis: Array = _runner.type_visible_ids() # 本停点类型可见集(每帧一取——纯函数零状态)
# 连线(通道)先行画(方块覆盖其上);悬停预承诺 = 所属通道再增亮(加粗)
# 选路停点(候选非空)非候选通道降透明——金岔路从结构网分离(干线共线重叠缺陷的
# 视觉主因:不同源通道共用同一垂直干线,金边与石色边焊接成「整列可走」假象)
var dim_passages: bool = not cand_ids.is_empty()
for node in nodes:
var from: Vector2 = pos[node]
for tid in _out_ids(node, nodes):
var target := _node_by_id(tid, nodes)
if target.is_empty():
continue
var to: Vector2 = pos[target]
var lit: bool = _id_of(node) == current_id and cand_ids.has(tid)
var hovered: bool = lit and _hover_id == tid
var col := COL_PASSAGE_LIT if lit else (Color(COL_PASSAGE, 0.35) if dim_passages else COL_PASSAGE)
var width := 4.0 if hovered else (3.0 if lit else 2.0) # 2/3/4 三档
_draw_passage(canvas, from, to, col, width)
# 房间方块
for node in nodes:
var p: Vector2 = pos[node]
var t := String(node.get("type", ""))
var nid := _id_of(node)
var col := COL_ROOM_FUTURE
if visited.has(nid):
col = COL_ROOM_DONE
if nid == current_id:
col = COL_ROOM_CURRENT
var rect := Rect2(p - NODE_HALF, NODE_HALF * 2.0)
canvas.draw_rect(rect, col)
var disp := node_display(node, nid, type_vis, nid == current_id, cand_ids.has(nid))
if bool(disp["boss_border"]):
# Boss 描边 = 紫赤 2px(§4.2 色相独占)+ 外缘 1px 深墨环(灰度冗余通道强制:
# 紫赤灰度 72 对石纹噪底 65 灰度不可辨;灰度/色弱下 Boss 环经深墨环可辨)
canvas.draw_rect(Rect2(rect.position - Vector2(3, 3), rect.size + Vector2(6, 6)), UiKit.INK_ROUTE, false, 1.0)
canvas.draw_rect(rect, COL_BOSS_BORDER, false, 2.0)
elif cand_ids.has(nid):
# 悬停预承诺 = 亮金描边加粗(4px),常态候选 = 亮金描边 2px(spec States 悬停预承诺行)
canvas.draw_rect(rect, COL_PASSAGE_LIT, false, 4.0 if _hover_id == nid else 2.0)
# 节点图标(20px 烘焙 1:1 消费;双色墨制=乘法调制——当前金底深墨,暗底系 WARM 原色;
# 贴图缺位 → 字形回退=贴图在位字形退役惯例)
if bool(disp["draw_icon"]):
var icon: Texture2D = _node_icons.get(t)
if icon != null:
var tint := UiKit.ICON_TINT_ON_GOLD if col == COL_ROOM_CURRENT else Color(1.0, 1.0, 1.0)
canvas.draw_texture_rect(icon, Rect2(p - Vector2(NODE_ICON_PX, NODE_ICON_PX) / 2.0,
Vector2(NODE_ICON_PX, NODE_ICON_PX)), false, tint)
else:
var font := get_theme_default_font()
var label := String(NODE_LABELS.get(t, "?"))
canvas.draw_string(font, p + Vector2(-6, 2), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 16, UiKit.INK_ROUTE)
# 编号(右上角——与图标占位区零重叠;双色墨随态)
var num_ink := UiKit.INK_ROUTE if col == COL_ROOM_CURRENT else UiKit.WARM
canvas.draw_string(get_theme_default_font(), Vector2(rect.end.x - 16.0, rect.position.y + 8.0),
str(nid), HORIZONTAL_ALIGNMENT_RIGHT, 14, 10, num_ink)
# 类型文字标签(demo ④:节点下方 11px;当前位=「当前位置」金、候选=类型名+「 · 可选」烛光黄、
# 其余=类型名暖米弱化;文字+图标+形状三通道)
if bool(disp["draw_label"]):
var type_ink := COL_ROOM_CURRENT if nid == current_id else (UiKit.CANDLE if cand_ids.has(nid) else UiKit.WARM_FAINT)
canvas.draw_string(get_theme_default_font(), Vector2(p.x - 44.0, rect.end.y + 15.0),
String(disp["label_text"]), HORIZONTAL_ALIGNMENT_CENTER, 88, 11, type_ink)
func _layout(nodes: Array, canvas: Control) -> Dictionary:
var layer := {}
var slot := {}
_assign_layers(nodes, layer, slot)
var pos := {}
var origin := (canvas.size - _content_size) / 2.0
for node in nodes:
pos[node] = origin + Vector2(LAYER_BASE_X + int(layer[node]) * LAYER_STEP_X,
SLOT_BASE_Y + int(slot[node]) * SLOT_STEP_Y)
return pos
# ---- 探针只读出口(形态仿 fx_probe 先例:只读快照) ----
# 选路布局只读快照(node → 局部位置;画布固定取现行 _canvas,调用侧零私有域触达)
func layout_probe(nodes: Array) -> Dictionary:
return _layout(nodes, _canvas)
# 路由画布只读快照(origin=全局原点;min_size=最小拟合尺寸——画布拟合断言消费面)
func canvas_probe() -> Dictionary:
return {"origin": _canvas.get_global_position(), "min_size": _canvas.custom_minimum_size}
# 类型可见性呈现只读快照(id → node_display 决策输出;真机绘制面断言消费点)
func display_probe() -> Dictionary:
var out := {}
if _runner == null:
return out
var nodes: Array[Variant] = _runner.nodes()
var type_vis: Array = _runner.type_visible_ids()
var current_id: int = _runner.current_node_id()
var cand_ids: Array = _runner.next_candidate_ids()
for node in nodes:
var nid := _id_of(node)
out[nid] = node_display(node, nid, type_vis, nid == current_id, cand_ids.has(nid))
return out
# 分层(layout/fit 共享件):layer = 入口 BFS 深度;同层 slot 垂直排布;未达节点兜底排末层
func _assign_layers(nodes: Array, layer: Dictionary, slot: Dictionary) -> void:
var layer_counts := {}
var entry := _entry(nodes)
var queue: Array[Dictionary] = []
if not entry.is_empty():
queue.append(entry)
layer[entry] = 0
while not queue.is_empty():
var cur: Dictionary[Variant, Variant] = queue.pop_front()
var l: int = int(layer[cur])
layer_counts[l] = int(layer_counts.get(l, 0)) + 1
slot[cur] = int(layer_counts[l]) - 1
for tid in _out_ids(cur, nodes):
var target := _node_by_id(tid, nodes)
if target.is_empty() or layer.has(target):
continue
layer[target] = l + 1
queue.append(target)
var max_layer := 0
for k in layer.keys():
max_layer = maxi(max_layer, int(layer[k]))
for node in nodes:
if not layer.has(node):
layer[node] = max_layer + 1
slot[node] = int(layer_counts.get(layer[node], 0))
layer_counts[layer[node]] = int(layer_counts.get(layer[node], 0)) + 1
func _entry(nodes: Array) -> Dictionary:
var inbound := {}
for node in nodes:
for tid in _out_ids(node, nodes):
inbound[tid] = true
for node in nodes:
if not inbound.has(_id_of(node)):
return node
return {}
func _out_ids(node: Dictionary, _nodes: Array) -> Array:
return _runner.node_out_ids(node)
func _node_by_id(nid: int, nodes: Array) -> Dictionary:
for node in nodes:
if _id_of(node) == nid:
return node
return {}
# 通道画法:房间方块边缘到边缘的水平-折线(占位观感——地牢通道)
func _draw_passage(canvas: Control, from: Vector2, to: Vector2, col: Color, width: float) -> void:
var a := from + Vector2(NODE_HALF.x, 0)
var b := to - Vector2(NODE_HALF.x, 0)
if b.x < a.x:
return
# 垂直干线 x 按源节点 y 偏移——不同源的边不再共线重叠(共线会把相邻层通道焊成一条干系,
# 「金岔路+整列可走」误读的直接几何根源)。同源双边仍共线(同出发
# 点语义正确),跨源分离度 = 源行距 × 0.2(行距 ~58px → 偏移 ~12px 可辨)。
var mid_x := (a.x + b.x) / 2.0 + (a.y - b.y) * 0.2
var pts := PackedVector2Array([a, Vector2(mid_x, a.y), Vector2(mid_x, b.y), b])
canvas.draw_polyline(pts, col, width)