## VERTICAL SLICE - NOT FOR PRODUCTION ## 关卡画面·独立屏:全屏不透明独占呈现(激活期战斗屏宿主由组合根整体隐藏),节点图选路呈现。 ## 前进唯一通道=图上点候选节点(route_chosen 外发)——零自动推进;放弃入口可见性归组合根(仅冒险模式开)。 ## 内部数据类 _MapState=宿主画面登记表(只被本文件填读,文件外零消费者——字段为公开数据成员)。 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) const DRAG_THRESHOLD := 8.0 # 拖动距离阈值(布局决策 5 provisional——超阈值进拖拽态,拖拽不触发选路) const WHEEL_STEP := 60.0 # 滚轮横滚单档步进(provisional 实现期定值;方向映射=代拍板:下/右=向右前进向) ## 内部状态件(u2 子域拆件——契约 9 职责轴):_MapState=选路屏运行态与图绘制态(单一变化原因= ## 选路呈现)。layer_decor 域字典承载换层装饰三件(图标贴图/石纹面/tint 层号——装饰域归并形态)。 ## 探针直读冻结成员(_cand_ids/_hover_id/_content_size/_layout_cache/abandon_btn/status_badge)住宿主。 class _MapState extends RefCounted: var runner = null var title: Label var canvas: Control var viewport: LevelMapViewport # 卷轴视口承载件(布局决策 5——画布宿主/裁切/clamp/跟随/指引) 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 abandon_timer := ArmedTimer.new() # 放弃确认窗(_process 帧驱动倒计时机制收口;窗口=CONFIRM_WINDOW_S) var drawn_disp: Dictionary[int, Dictionary] = {} # 本停点实际绘制记录(_draw_graph 逐帧在实际绘制点重建——display_probe 消费面) var layer_decor := {"node_icons": {}, "noise_rect": null, "tint_level": 0} # 换层装饰域(节点图标贴图 load_tex 守卫缺位 null→字形回退/屏底石纹面/当前已应用 tint 层号 0=未应用) var _cand_ids: Array[int] = [] # 本停点候选 id 集(图上命中合法集;探针直读冻结——住宿主) var _content_size := Vector2(620, 240) # 图内容包络(open_choices 按图拟合——画布 min 尺寸随它;探针直读冻结) var _hover_id := -1 # 悬停预承诺候选(-1 = 无;探针直读冻结) var _drag_active := false # 拖拽态(超阈值至抬杆——拖拽不触发选路,布局决策 5) var _drag_press_pos := Vector2(-1, -1) # 拖距判定基准(按下点画布局部坐标;-1 = 无按下跟踪) var abandon_btn: Button # 放弃入口(可见性归组合根 set_abandon_available——只在冒险模式开;探针直读冻结) var status_badge: Button # 状态面板入口角标(顶栏 GP 徽右侧;连接归组合根;探针直读冻结) var _layout_cache := {"nodes": [], "canvas_size": Vector2.ZERO, "pos": {}} # 布局缓存(_layout 纯函数于 (nodes, canvas)——键=nodes 内容等价+画布尺寸,图态换代或画布尺寸变即自然失效;探针直读冻结) var _map := _MapState.new() func _init() -> void: _map.abandon_timer.expired.connect(_reset_abandon_confirm) # 节点图标贴图装载(load_tex 守卫,缺位回退现行字形) for t in TEX_NODE_ICONS: _map.layer_decor.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) _map.layer_decor.noise_rect = tex # 世界侧 tint 承载面(换层色板渐变挂点——§2.7 切片档) base.add_child(UiKit.vignette_rect()) # 三段纵排页(布局决策 5 卷轴视口:顶栏/标题在上、图区视口扩张充盈、底栏在下—— # 视口为窗口屏为框,画布随图生长可超屏) var page := VBoxContainer.new() page.set_anchors_preset(Control.PRESET_FULL_RECT) page.add_theme_constant_override("separation", 10) base.add_child(page) _build_top_bar(page) _build_canvas(page) _build_status_bar(page) visible = false ## 顶栏域(spec Layout:左关卡名 / 右 GP 常驻徽章——直角细线数据框; ## 对齐 demo ④:关卡名入框板+GP 徽章=烛光胶囊(同商店GP框)+ 左右两极排布; ## 状态角标置灰守卫归组合根 _sync_status_entry,信号连接归组合根——视图零连接) func _build_top_bar(box: VBoxContainer) -> void: 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) _map.level_label = UiKit.label("",UiKit.FS_BODY, true, UiKit.WARM) lvl_plate.add_child(_map.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) _map.gp_value = UiKit.label("",UiKit.FS_BODY, true, UiKit.INK_ON_GOLD) gp_row.add_child(_map.gp_value) 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) _map.title = UiKit.label(TITLE_FORK,UiKit.FS_SUB, true, UiKit.WARM) _map.title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER box.add_child(_map.title) ## 节点图区(卷轴视口承载——布局决策 5):视口件为窗口(裁切/offset/clamp/跟随/指引), ## 画布为其子件;图上点击 = 唯一提交通道(布局决策 2),拖拽/滚轮判定留在画布 _gui_input ## (事件流同源——悬停/选路同源,视口件不做父子事件路由) func _build_canvas(box: VBoxContainer) -> void: _map.canvas = Control.new() _map.canvas.custom_minimum_size = Vector2(620, 240) _map.canvas.mouse_filter = Control.MOUSE_FILTER_STOP _map.canvas.draw.connect(_draw_graph.bind(_map.canvas)) _map.canvas.gui_input.connect(_on_canvas_input) _map.canvas.mouse_exited.connect(_on_canvas_mouse_exited) _map.viewport = LevelMapViewport.new() _map.viewport.size_flags_horizontal = Control.SIZE_EXPAND_FILL _map.viewport.size_flags_vertical = Control.SIZE_EXPAND_FILL box.add_child(_map.viewport) _map.viewport.setup(_map.canvas, _viewport_anchor, Vector2(1280, 800)) # 兜底尺寸=设计基座(实况窗口以布局为准) ## 底部状态栏域(合并单条:亮米面板一站读齐=头像+生命+属性+本关加成/流派件——demo ④; ## 道具槽不摆=道具读数无关卡画面成文出口(读快照私域越权); ## hp_value/attr_value/bonus_value/feat_value 投喂键全部保留。 ## 底栏定高纪律(对齐 demo——单条 ~85px 紧凑亮条;任何子件不再能把栏撑爆: ## 特性持有行限宽截断+tooltip 全文)) func _build_status_bar(box: VBoxContainer) -> void: 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) strip.add_child(_build_face_rect()) var life_box := VBoxContainer.new() strip.add_child(life_box) life_box.add_child(UiKit.label("生命",UiKit.FS_CAPTION, false, UiKit.INK_HINT)) _map.hp_value = UiKit.label("",UiKit.FS_BODY, true, UiKit.INK_TITLE) life_box.add_child(_map.hp_value) _map.hp_bar = Control.new() _map.hp_bar.custom_minimum_size = Vector2(130, 7) _map.hp_bar.draw.connect(_draw_hp_bar.bind(_map.hp_bar)) life_box.add_child(_map.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)) _map.attr_value = UiKit.label("",UiKit.FS_BODY, true, UiKit.INK_TITLE) attr_box.add_child(_map.attr_value) strip.add_child(_vdiv()) _build_bonus_group(strip) 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) ## 头像域:定高 48px 框、宽随源图比自适应(源 = 原稿分辨率;该面为独立消费面—— ## 显示档批不改框尺寸,画面随素材分辨率自动变化) func _build_face_rect() -> TextureRect: var face := TextureRect.new() var face_tex := UiKit.load_tex(UiKit.TEX_HERO_PORTRAIT) 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 return face ## 本关加成框域(零加成隐藏——spec 布局决策 4)+ 流派件持有行(组合根投喂;限宽单行截断+tooltip 全文) func _build_bonus_group(strip: HBoxContainer) -> void: _map.bonus_row = HBoxContainer.new() strip.add_child(_map.bonus_row) var bonus_box := VBoxContainer.new() _map.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) _map.bonus_value = UiKit.label("",UiKit.FS_BODY, true, UiKit.INK_TITLE) # 淡墨→深墨(同上) bonus_inner.add_child(_map.bonus_value) _map.feat_value = UiKit.label("",UiKit.FS_HINT, false, UiKit.INK_HINT) _map.feat_value.custom_minimum_size = Vector2(240, 0) _map.feat_value.clip_text = true _map.feat_value.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS bonus_box.add_child(_map.feat_value) # 竖分隔线(底栏分组线——亮面板上低对比细线) 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 _map.runner == null: return var max_hp := maxi(1, _map.runner.effective_max_hp()) var ratio := clampf(float(_map.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: _map.feat_value.text = text _map.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 _map.abandon_timer.is_armed(): _map.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: _map.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 _map.abandon_timer.is_armed(): return _map.abandon_timer.tick(delta) if not _map.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(_map.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 # 进入转场判据(就地刷新/模态栈恢复不重放淡入) _map.runner = runner _hover_id = -1 _reset_abandon_confirm() var visited: Array[int] = [] if runner != null: visited = runner.visited_ids() if cands.size() > 1: _map.title.text = TITLE_FORK elif visited.is_empty(): _map.title.text = TITLE_ENTRY # 入口停点(尚未走出第一步) else: _map.title.text = TITLE_SINGLE _cand_ids = [] for cand in cands: _cand_ids.append(_id_of(cand)) _fit_canvas(_map.runner.nodes()) _apply_level_tint(int(_map.runner.level_row.get("level_index", 0))) visible = true # 视口锚定(布局决策 5):入口停点(尚无当前节点)=首锚即达;其余停点/兑付回图=平滑跟随 # (停点切换与兑付确认回图——减动效开由视口件跳终态) _map.viewport.reanchor(_map.runner.current_node_id() >= 0) 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)) # 下限(抬高容纳标签行) _map.canvas.custom_minimum_size = _content_size _map.canvas.size = _content_size # 同帧收敛(视口锚点/包络数学与 _layout 同源读画布尺寸——容器延迟收敛不等锚点) # 换层色板渐变挂点(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 _map.layer_decor.noise_rect == null or level_index == _map.layer_decor.tint_level: return _map.layer_decor.tint_level = level_index var target: Color = BOARD_TINTS.get(level_index, Color(1, 1, 1)) var tw := create_tween() tw.tween_property(_map.layer_decor.noise_rect, "modulate", Color(target.r, target.g, target.b, UiKit.BACKDROP_TILE_ALPHA), UiKit.motion(0.45)) # 0.14 弱质感档随 tint 保持 # 状态栏投喂(命令式纪律——组合根经 level_map_status_payload 取成品后喂入;零推送信号订阅、零核心态拼装) func apply_status(payload: Dictionary) -> void: _map.level_label.text = String(payload["level_label"]) _map.gp_value.text = String(payload["gp_text"]) _map.hp_value.text = String(payload["hp_text"]) if bool(payload["hp_bar_redraw"]): _map.hp_bar.queue_redraw() _map.attr_value.text = String(payload["attr_text"]) _map.bonus_value.text = String(payload["bonus_text"]) # 特性持有行可见性(与加成行并框——任一非空整框显示;文案归组合根 set_feat_holdings) _map.bonus_row.visible = bool(payload["bonus_row_visible"]) or _map.feat_value.text != "" # ---- 图上命中(布局决策 2——唯一提交通道;探针 press_candidate 同路径) ---- # 拖拽与点击以拖动距离阈值区分(布局决策 5):按下起跟踪拖距,超阈值进拖拽态逐帧平移视口, # 抬杆不提交;未超阈值的完整点击(按下→抬杆)=提交沿——命中候选即提交,语义零改动。 func _on_canvas_input(ev: InputEvent) -> void: if _map.runner == null or not visible: return if ev is InputEventMouseButton and ev.pressed and _wheel_pan_step(ev.button_index) != 0.0: _map.viewport.pan_by(Vector2(_wheel_pan_step(ev.button_index), 0)) elif ev is InputEventMouseMotion: _on_canvas_motion(ev) elif ev is InputEventMouseButton and ev.button_index == MOUSE_BUTTON_LEFT: _on_canvas_click(ev) ## 悬停预承诺与拖拽态推进沿(画布局部坐标——gui_input 事件流同源) func _on_canvas_motion(ev: InputEventMouseMotion) -> void: if _drag_active: _map.viewport.pan_by(ev.relative) # 拖拽态逐帧平移(视口件执行 clamp——本屏只判定) return if _drag_press_pos.x >= 0.0 and ev.position.distance_to(_drag_press_pos) >= DRAG_THRESHOLD: _drag_active = true _drag_press_pos = Vector2(-1, -1) if _hover_id != -1: # 进拖拽态清悬停预承诺(拖拽非选路意图) _hover_id = -1 _map.canvas.mouse_default_cursor_shape = Control.CURSOR_ARROW _map.canvas.queue_redraw() _map.viewport.pan_by(ev.relative) # 跨阈值帧位移计入平移(拖距全长=平移全长) return var nid := _candidate_at(ev.position) # gui_input 事件坐标 = 接收控件局部坐标(push_input 同成立) if nid != _hover_id: _hover_id = nid _map.canvas.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if nid >= 0 else Control.CURSOR_ARROW _map.canvas.queue_redraw() ## 点击沿与拖拽抬杆(未超阈值的完整点击=提交沿——布局决策 2 语义零改动) func _on_canvas_click(ev: InputEventMouseButton) -> void: if ev.pressed: _drag_press_pos = ev.position _drag_active = false return _drag_press_pos = Vector2(-1, -1) if _drag_active: _drag_active = false # 拖拽抬杆=平移结束,不触发选路提交(布局决策 5) return var nid2 := _candidate_at(ev.position) if nid2 >= 0: _submit_choice(nid2) ## 滚轮横滚步进(代拍板:下/右=向右前进向——视线移向深层,画布左移;上/左反向;非滚轮=0) func _wheel_pan_step(button_index: int) -> float: if button_index == MOUSE_BUTTON_WHEEL_DOWN or button_index == MOUSE_BUTTON_WHEEL_RIGHT: return -WHEEL_STEP if button_index == MOUSE_BUTTON_WHEEL_UP or button_index == MOUSE_BUTTON_WHEEL_LEFT: return WHEEL_STEP return 0.0 ## 拖距跟踪器生命周期清理(按下即武装——鼠标离画布且未入拖拽态时解除, ## 防丢失 release 沿后的残留 armed 态把后续 motion 误判成拖拽激活) func _on_canvas_mouse_exited() -> void: if not _drag_active: _drag_press_pos = Vector2(-1, -1) # 候选命中:位置 = 布局公式(与 _draw_graph 同源),矩形 = 节点方块 + 外扩容差;非候选格不响应 func _candidate_at(local_pos: Vector2) -> int: if _map.runner == null: return -1 var nodes: Array[Variant] = _map.runner.nodes() var pos := _layout(nodes, _map.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, _map.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 _map.runner == null: return var nodes: Array[Variant] = _map.runner.nodes() var node := _node_by_id(int(_cand_ids[index]), nodes) if node.is_empty(): return var pos: Vector2 = _layout(nodes, _map.canvas)[node] var nid := _candidate_at(pos) # 节点中心必命中自身(命中面含节点方块) if nid >= 0: _submit_choice(nid) # ---- 视口锚点(布局决策 5:当前节点居中;入口停点尚无当前节点=锚入口组包围盒居中) ---- ## 锚点画布坐标提供方(构造注入视口件——本屏供数,视口件只消费) func _viewport_anchor() -> Vector2: if _map.runner == null: return _content_size / 2.0 var nodes: Array[Variant] = _map.runner.nodes() var pos := _layout(nodes, _map.canvas) var cur_id: int = _map.runner.current_node_id() if cur_id < 0: return _entry_group_center(nodes, pos) # 入口停点(尚未走出第一步) var cur := _node_by_id(cur_id, nodes) if cur.is_empty(): return _content_size / 2.0 return pos[cur] func _entry_group_center(nodes: Array, pos: Dictionary) -> Vector2: # 入口组 = 全部无入边节点(与 _assign_layers 入口判据同源);多入口取包围盒居中 var inbound := {} for node in nodes: for tid in _out_ids(node, nodes): inbound[tid] = true var lo := Vector2(INF, INF) var hi := Vector2(-INF, -INF) for node in nodes: if inbound.has(_id_of(node)): continue var p: Vector2 = pos[node] lo = lo.min(p) hi = hi.max(p) if lo.x > hi.x: return _content_size / 2.0 # 空图兜底(无入口——不达路径,防御位) return (lo + hi) / 2.0 func _id_of(node: Dictionary) -> int: # 编号显示经行进器读面(编号空间解析单一出口——单链 = 下标 / 图 = node_depth) return _map.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 _map.runner == null or not visible: return _map.drawn_disp.clear() # 绘制记录随本帧重建——只反映实际发生的绘制(接线坏的判据面) var ctx := _draw_context(canvas) _draw_passage_layer(canvas, ctx) for node in ctx["nodes"]: _draw_room(canvas, node, ctx) ## 绘制上下文束(单次取数——布局/字体/编号空间/可见集,绘制域共享只读面) func _draw_context(canvas: Control) -> Dictionary: var nodes: Array[Variant] = _map.runner.nodes() return { "nodes": nodes, "pos": _layout(nodes, canvas), "font": get_theme_default_font(), # 绘制路径字体单次取用(三处 draw_string 共享——零语义差) "current_id": _map.runner.current_node_id(), # 当前节点 id(单链=下标 / 图=编号)——visited_ids() 已按编号空间解析 "visited": _map.runner.visited_ids(), "cand_ids": _map.runner.next_candidate_ids(), "type_vis": _map.runner.type_visible_ids(), # 本停点类型可见集(每帧一取——纯函数零状态) } ## 房间底色域(行进态三色:已到暗金/当前奖励金/未达暗——绘制域与图标域共享) func _room_color(node: Dictionary, ctx: Dictionary) -> Color: var nid := _id_of(node) var col := COL_ROOM_FUTURE if (ctx["visited"] as Array).has(nid): col = COL_ROOM_DONE if nid == ctx["current_id"]: col = COL_ROOM_CURRENT return col ## 通道层绘制域:连线先行画(方块覆盖其上);悬停预承诺 = 所属通道再增亮(加粗)。 ## 选路停点(候选非空)非候选通道降透明——金岔路从结构网分离(干线共线重叠缺陷的 ## 视觉主因:不同源通道共用同一垂直干线,金边与石色边焊接成「整列可走」假象) func _draw_passage_layer(canvas: Control, ctx: Dictionary) -> void: var nodes: Array = ctx["nodes"] # type-hint: ignore(ctx 束 Variant 出值——布局缓存数组未参数化) var pos: Dictionary = ctx["pos"] # type-hint: ignore(同上——节点 id → 坐标) var cand_ids: Array = ctx["cand_ids"] # type-hint: ignore(同上——候选 id 集) 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 lit: bool = _id_of(node) == ctx["current_id"] and cand_ids.has(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 lit and _hover_id == tid else (3.0 if lit else 2.0) # 2/3/4 三档 _draw_passage(canvas, from, pos[target], col, width) ## 房间方块域:底色随行进态+Boss/候选描边+图标/字形+编号+类型文字标签 func _draw_room(canvas: Control, node: Dictionary, ctx: Dictionary) -> void: var pos: Dictionary = ctx["pos"] # type-hint: ignore(ctx 束 Variant 出值——节点 id → 坐标) var p: Vector2 = pos[node] var t := String(node.get("type", "")) var nid := _id_of(node) var col := _room_color(node, ctx) var rect := Rect2(p - NODE_HALF, NODE_HALF * 2.0) canvas.draw_rect(rect, col) var disp := node_display(node, nid, ctx["type_vis"], nid == ctx["current_id"], (ctx["cand_ids"] as Array).has(nid)) var rec: Dictionary[String, Variant] = {"draw_icon": false, "draw_label": false, "label_text": "", "boss_border": false} 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) rec["boss_border"] = true elif (ctx["cand_ids"] as Array).has(nid): # 悬停预承诺 = 亮金描边加粗(4px),常态候选 = 亮金描边 2px(spec States 悬停预承诺行) canvas.draw_rect(rect, COL_PASSAGE_LIT, false, 4.0 if _hover_id == nid else 2.0) if _draw_room_icon(canvas, node, disp, ctx): rec["draw_icon"] = true # 编号(右上角——与图标占位区零重叠;双色墨随态) var num_ink := UiKit.INK_ROUTE if col == COL_ROOM_CURRENT else UiKit.WARM canvas.draw_string(ctx["font"], Vector2(rect.end.x - 16.0, rect.position.y + 8.0), str(nid), HORIZONTAL_ALIGNMENT_RIGHT, 14, 10, num_ink) var label_rec := _draw_room_label(canvas, rect, disp, nid, ctx) rec["draw_label"] = bool(label_rec.get("draw_label", false)) rec["label_text"] = String(label_rec.get("label_text", "")) _map.drawn_disp[nid] = rec ## 节点图标域(20px 烘焙 1:1 消费;双色墨制=乘法调制——当前金底深墨,暗底系 WARM 原色; ## 贴图缺位 → 字形回退=贴图在位字形退役惯例) func _draw_room_icon(canvas: Control, node: Dictionary, disp: Dictionary, ctx: Dictionary) -> bool: if not bool(disp["draw_icon"]): return false var p: Vector2 = ctx["pos"][node] var col := _room_color(node, ctx) var t := String(node.get("type", "")) var icon: Texture2D = _map.layer_decor.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 label := String(NODE_LABELS.get(t, "?")) canvas.draw_string(ctx["font"], p + Vector2(-6, 2), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 16, UiKit.INK_ROUTE) return true ## 类型文字标签域(demo ④:节点下方 11px;当前位=「当前位置」金、候选=类型名+「 · 可选」烛光黄、 ## 其余=类型名暖米弱化;文字+图标+形状三通道) func _draw_room_label(canvas: Control, rect: Rect2, disp: Dictionary, nid: int, ctx: Dictionary) -> Dictionary: if not bool(disp["draw_label"]): return {} var p: Vector2 = rect.position + NODE_HALF # 节点中心反推(方块原点=中心−半宽高) var type_ink := COL_ROOM_CURRENT if nid == ctx["current_id"] else (UiKit.CANDLE if (ctx["cand_ids"] as Array).has(nid) else UiKit.WARM_FAINT) canvas.draw_string(ctx["font"], Vector2(p.x - 44.0, rect.end.y + 15.0), String(disp["label_text"]), HORIZONTAL_ALIGNMENT_CENTER, 88, 11, type_ink) return {"draw_label": true, "label_text": String(disp["label_text"])} func _layout(nodes: Array, canvas: Control) -> Dictionary: # 缓存命中键 = nodes 内容等价(GDScript Array== 逐元素比较)+画布尺寸——布局是二者与 # _content_size(经 _fit_canvas 派生于 nodes)的纯函数,键间接覆盖派生链;换代即自然失效。 if _layout_cache.nodes == nodes and _layout_cache.canvas_size == canvas.size: return _layout_cache.pos 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) _layout_cache.nodes = nodes _layout_cache.canvas_size = canvas.size _layout_cache.pos = pos return pos # ---- 探针只读出口(形态仿 fx_probe 先例:只读快照) ---- # 选路布局只读快照(node → 局部位置;画布固定取现行 _map.canvas,调用侧零私有域触达) func layout_probe(nodes: Array) -> Dictionary: return _layout(nodes, _map.canvas) # 路由画布只读快照(origin=全局原点;min_size=最小拟合尺寸——画布拟合断言消费面) func canvas_probe() -> Dictionary: return {"origin": _map.canvas.get_global_position(), "min_size": _map.canvas.custom_minimum_size} # 类型可见性绘制只读记录(id → 本帧实际绘制事实:图标/文字/描边/文案;_draw_graph 在实际绘制点写入—— # 接线层断言消费点:绕开决策面直接画,这里就露出「雾下也画了」) func display_probe() -> Dictionary: return _map.drawn_disp # 分层(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 _map.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)