_submit_choice 节点类型分支:战斗/Boss=出屏(visible=false 后发射);奖励/恢复/商店=本屏保持激活发射——兑付覆盖层由节点事件在本屏域内弹出(模态栈打开低层自动隐藏高层本屏、关闭自动恢复,零机制改动复用),兑付确认回图即本屏就地刷新(open_choices 幂等,was_visible 判据不重放淡入)。组合根 _on_route_chosen 改视图去留随动:出屏才收栈记录+_exit_level_screen 回战斗屏;放弃/冒险终局强制回战斗屏呈现摘要(T2 已落)。零自动推进纪律零改动。轻闸=冒烟 557/0+五探针 PASS(click_probe 岔路/商店路径含域内弹出全程实测)。
467 lines
20 KiB
GDScript
467 lines
20 KiB
GDScript
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||
# Date: 2026-09-17(分支节点图批——关卡节点地图 v1.0 图版选路呈现)
|
||
# 2026-09-28 关卡画面实装批(M-1 迭代 1 T4——level-map.md 三拍板落地):
|
||
# ① 三停点标题定稿(布局决策 1——清偿台账 143:「继续前进(战斗房)」入口措辞矛盾退役)
|
||
# ② 图上点击选路唯一通道(布局决策 2)——候选按钮整列退役;候选节点亮金描边 = 可点目标,
|
||
# 悬停预承诺(描边加粗+指针)后点击提交;press_candidate 探针入口改走图上命中同路径
|
||
# ③ 放弃冒险二次确认(布局决策 3)——首击转确认态(文案+危险红脉冲约 3 秒超时回落),二击提交
|
||
# ④ 顶栏语境:关卡名(level_row.display_name)+ GP 常驻徽章(2026-09-26 二轮拍板去向落地面)
|
||
# ⑤ 底部状态栏(布局决策 4):生命/属性/本关加成三组常读(零加成隐藏),放弃按钮右端异位
|
||
# 关卡画面(独立屏,Level Map Screen——2026-09-28 形态升格批 M-2 迭代 3 T2:原 RouteOverlay 模态
|
||
# 覆盖层升格独立屏,spec level-map.md 形态章节同批改写):全屏不透明独占呈现——激活期战斗屏宿主
|
||
# 由组合根整体隐藏(非暗幕半透、零透出)。零自动推进的玩家停点语义不变:唯一前进通道 =
|
||
# 图上点候选节点 → route_chosen(node_id) → 组合根转 runner.pick_next_node。
|
||
# 高内聚边界:只读 runner 成文出口(nodes()/visited_ids()/node_index/next_candidate_ids()/level_row/
|
||
# hero_hp/gp/effective_*/bonus)做渲染,无任何写面;图形为程序化占位质量(占位纪律)。
|
||
# 放弃入口(2026-09-21 用户拍板「放弃接口也要有」——台账 119):选路停点 = 天然非战斗期(接口契约:
|
||
# 战斗中放弃一律拒绝),按钮常驻底栏右端;可见性归组合根(只在冒险模式开——放弃的语义对象是
|
||
# "整局冒险";调试两层无节点图天然不可达)。
|
||
class_name LevelMapScreen
|
||
extends Control
|
||
|
||
signal route_chosen(node_id: int)
|
||
signal abandon_requested
|
||
|
||
const COL_ROOM_DONE := 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.GOLD_LIT # 候选通道——亮金
|
||
const COL_BOSS_BORDER := UiKit.BOSS_PURPLE # Boss 边框——紫赤(色相独占本色;旗朱退役 2026-09-26)
|
||
const COL_ABANDON := UiKit.RED_MINE # 破坏性动作红(危险红族)
|
||
const NODE_LABELS := {"battle": "战", "reward": "赏", "rest": "疗", "boss": "王", "shop": "商"} # 商 = GP+商店房实施批 2026-09-21
|
||
const TITLE_ENTRY := "出发——选择首个节点" # 入口停点(布局决策 1——台账 143 遗留清偿)
|
||
const TITLE_SINGLE := "继续前进" # 单链停点
|
||
const TITLE_FORK := "选择下一节点" # 岔路停点
|
||
const NODE_HALF := Vector2(26, 18) # 节点方块半宽高(52×36 占位画布参数——样张批终稿)
|
||
const HIT_PAD := 6.0 # 图上命中外扩(指宽容差)
|
||
const CONFIRM_WINDOW_S := 3.0 # 放弃确认态窗口(秒——超时回落)
|
||
|
||
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 _cand_ids: Array = [] # 本停点候选 id 集(图上命中合法集)
|
||
var _content_size := Vector2(620, 240) # 图内容包络(open_choices 按图拟合——画布 min 尺寸随它)
|
||
var _hover_id := -1 # 悬停预承诺候选(-1 = 无)
|
||
var abandon_btn: Button # 放弃入口(可见性归组合根 set_abandon_available——只在冒险模式开)
|
||
var _abandon_armed := false # 二次确认态(首击置位、二击提交、超时/重开回落)
|
||
var _confirm_left := 0.0 # 确认态剩余秒数(_process 倒计时+脉冲驱动)
|
||
|
||
|
||
func _init() -> void:
|
||
# 独立屏底(形态升格 2026-09-28——spec Layout:深底+石纹底噪铺满;底纹缺位回退纯色——load_tex 守卫)
|
||
var base := ColorRect.new()
|
||
base.color = UiKit.BG_ROOT
|
||
base.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
add_child(base)
|
||
var noise := UiKit.load_tex(UiKit.TEX_BACKDROP_STONE)
|
||
if noise != null:
|
||
var tex := TextureRect.new()
|
||
tex.texture = noise
|
||
tex.stretch_mode = TextureRect.STRETCH_TILE
|
||
tex.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||
tex.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
tex.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
base.add_child(tex)
|
||
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 常驻徽章——直角细线数据框)
|
||
var top := HBoxContainer.new()
|
||
top.add_theme_constant_override("separation", 12)
|
||
box.add_child(top)
|
||
_level_label = UiKit.label("", 16, true)
|
||
_level_label.add_theme_color_override("font_color", UiKit.WARM)
|
||
_level_label.custom_minimum_size.x = 420
|
||
top.add_child(_level_label)
|
||
var gp_box := PanelContainer.new()
|
||
gp_box.add_theme_stylebox_override("panel", UiKit.panel_flat(UiKit.DARK_UPCOMING, UiKit.PANEL_BORDER, 0, 1))
|
||
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("", 16, true)
|
||
_gp_value.add_theme_color_override("font_color", UiKit.GOLD)
|
||
gp_row.add_child(_gp_value)
|
||
_title = UiKit.label(TITLE_FORK, 18, true)
|
||
_title.add_theme_color_override("font_color", 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)
|
||
# 底部状态栏(布局决策 4:生命/属性/本关加成三组 + 放弃按钮右端异位)
|
||
var bottom := HBoxContainer.new()
|
||
bottom.add_theme_constant_override("separation", 10)
|
||
box.add_child(bottom)
|
||
bottom.add_child(_status_box("生命", true))
|
||
bottom.add_child(_status_box("属性", false))
|
||
# 本关加成框(零加成隐藏——spec 布局决策 4)
|
||
_bonus_row = HBoxContainer.new()
|
||
_bonus_row.add_theme_constant_override("separation", 10)
|
||
var bonus_box := PanelContainer.new()
|
||
bonus_box.add_theme_stylebox_override("panel", UiKit.panel_flat(UiKit.DARK_UPCOMING, UiKit.PANEL_BORDER, 0, 1))
|
||
_bonus_row.add_child(bonus_box)
|
||
var bonus_inner := VBoxContainer.new()
|
||
bonus_box.add_child(bonus_inner)
|
||
bonus_inner.add_child(UiKit.label("本关加成", 12, false))
|
||
_bonus_value = UiKit.label("", 15, true)
|
||
bonus_inner.add_child(_bonus_value)
|
||
bottom.add_child(_bonus_row)
|
||
var spacer := Control.new()
|
||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
bottom.add_child(spacer)
|
||
abandon_btn = UiKit.button("放弃冒险(同失败结算——已获大奖励保留)")
|
||
abandon_btn.add_theme_color_override("font_color", COL_ABANDON) # 破坏性动作红(危险红族)
|
||
abandon_btn.pressed.connect(_on_abandon_pressed)
|
||
abandon_btn.visible = false
|
||
bottom.add_child(abandon_btn)
|
||
visible = false
|
||
|
||
|
||
# 状态数据框工厂(直角细线数据框——pattern 5 族;hp_box = 带迷你血条)
|
||
func _status_box(caption: String, with_bar: bool) -> PanelContainer:
|
||
var panel := PanelContainer.new()
|
||
panel.add_theme_stylebox_override("panel", UiKit.panel_flat(UiKit.DARK_UPCOMING, UiKit.PANEL_BORDER, 0, 1))
|
||
var inner := VBoxContainer.new()
|
||
panel.add_child(inner)
|
||
inner.add_child(UiKit.label(caption, 12, false))
|
||
if with_bar:
|
||
_hp_bar = Control.new()
|
||
_hp_bar.custom_minimum_size = Vector2(120, 6)
|
||
_hp_bar.draw.connect(_draw_hp_bar.bind(_hp_bar))
|
||
inner.add_child(_hp_bar)
|
||
var value := UiKit.label("", 15, true)
|
||
inner.add_child(value)
|
||
if caption == "生命":
|
||
_hp_value = value
|
||
else:
|
||
_attr_value = value
|
||
return panel
|
||
|
||
|
||
func _draw_hp_bar(bar: Control) -> void:
|
||
# 迷你血条(占位:暗底+金填充——比例 = 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, UiKit.DARK_UPCOMING)
|
||
if ratio > 0.0:
|
||
bar.draw_rect(Rect2(Vector2.ZERO, Vector2(bar.size.x * ratio, bar.size.y)), UiKit.GOLD)
|
||
|
||
|
||
# 放弃入口可见性(组合根在冒险启动时开、默认关——语义对象 = 整局冒险)
|
||
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_armed:
|
||
_abandon_armed = true
|
||
_confirm_left = CONFIRM_WINDOW_S
|
||
abandon_btn.text = "再点一次确认放弃——不可逆"
|
||
return
|
||
_reset_abandon_confirm()
|
||
abandon_requested.emit()
|
||
|
||
|
||
func _reset_abandon_confirm() -> void:
|
||
_abandon_armed = false
|
||
_confirm_left = 0.0
|
||
abandon_btn.modulate = Color(1, 1, 1, 1)
|
||
abandon_btn.text = "放弃冒险(同失败结算——已获大奖励保留)"
|
||
|
||
|
||
func _process(delta: float) -> void:
|
||
if not _abandon_armed:
|
||
return
|
||
_confirm_left -= delta
|
||
if _confirm_left <= 0.0:
|
||
_reset_abandon_confirm()
|
||
return
|
||
# 危险红边框脉冲(单段呼吸约 1Hz——闪烁基线 ≤3 次/秒内;减动效 = 静态半透不闪)
|
||
if UiKit.motion_scale < 1.0:
|
||
abandon_btn.modulate = Color(1, 1, 1, 0.75)
|
||
else:
|
||
var k := 0.5 + 0.5 * sin(_confirm_left * TAU)
|
||
abandon_btn.modulate = Color(1, 1, 1, 0.65 + 0.35 * k)
|
||
|
||
|
||
# 打开选路口(cands = 候选节点引用数组;runner 只读用于整图渲染与状态栏刷新)
|
||
func open_choices(cands: Array, runner) -> void:
|
||
var was_visible := visible # 进入转场判据(就地刷新/模态栈恢复不重放淡入)
|
||
_runner = runner
|
||
_hover_id = -1
|
||
_reset_abandon_confirm()
|
||
var visited: Array = []
|
||
if runner != null:
|
||
visited = runner.visited_ids()
|
||
if cands.size() > 1:
|
||
_title.text = TITLE_FORK
|
||
elif visited.is_empty():
|
||
_title.text = TITLE_ENTRY # 入口停点(尚未走出第一步——台账 143 措辞矛盾清偿)
|
||
else:
|
||
_title.text = TITLE_SINGLE
|
||
_cand_ids = []
|
||
for cand in cands:
|
||
_cand_ids.append(_id_of(cand))
|
||
_fit_canvas(_runner.nodes())
|
||
_refresh_status()
|
||
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 同源;
|
||
# 2026-09-28 真鼠标点击 bug 根修:固定 620×240 画布下深层节点(L1 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(70 + maxl * 122 + NODE_HALF.x + 8.0, 52 + maxslot * 58 + NODE_HALF.y + 8.0)
|
||
_content_size = _content_size.max(Vector2(620, 240)) # 下限 = 旧定值画布(小图观感不变)
|
||
_canvas.custom_minimum_size = _content_size
|
||
|
||
|
||
# 状态栏刷新(命令式纪律——组合根打开本屏/兑付确认回执后重查;零推送信号订阅)
|
||
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.bonus.get("atk", 0))
|
||
var ac_bonus := int(_runner.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.bonus.get("max_hp", 0))
|
||
var parts: Array = []
|
||
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)
|
||
_bonus_row.visible = not parts.is_empty()
|
||
|
||
|
||
# ---- 图上命中(布局决策 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 = _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:
|
||
# 形态升格分支(2026-09-28 独立屏):战斗/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 = _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)
|
||
|
||
|
||
# ---- 整图自绘(程序化占位——分层布局:层 = 入口 BFS 深度,同层垂直排布)----
|
||
|
||
func _draw_graph(canvas: Control) -> void:
|
||
if _runner == null or not visible:
|
||
return
|
||
var nodes: Array = _runner.nodes()
|
||
var pos := _layout(nodes, canvas)
|
||
var current_id: int = _runner.current_node_id()
|
||
# 当前节点 id(单链=下标 / 图=编号)——visited_ids() 已按编号空间解析
|
||
var visited: Array = _runner.visited_ids()
|
||
var cand_ids: Array = _runner.next_candidate_ids()
|
||
# 连线(通道)先行画(方块覆盖其上);悬停预承诺 = 所属通道再增亮(加粗)
|
||
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 COL_PASSAGE
|
||
var width := 4.0 if hovered else (3.0 if lit else 1.5)
|
||
_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)
|
||
if t == "boss":
|
||
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)
|
||
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)
|
||
canvas.draw_string(font, p + Vector2(10, 14), str(nid), HORIZONTAL_ALIGNMENT_LEFT, -1, 10, UiKit.WARM_ROUTE_SUB)
|
||
|
||
|
||
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(70 + int(layer[node]) * 122, 52 + int(slot[node]) * 58)
|
||
return pos
|
||
|
||
|
||
# 分层(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 = []
|
||
if not entry.is_empty():
|
||
queue.append(entry)
|
||
layer[entry] = 0
|
||
while not queue.is_empty():
|
||
var cur: Dictionary = 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
|
||
var mid_x := (a.x + b.x) / 2.0
|
||
var pts := PackedVector2Array([a, Vector2(mid_x, a.y), Vector2(mid_x, b.y), b])
|
||
canvas.draw_polyline(pts, col, width)
|