Files
gplan-2/game/presentation/views/level_map_screen.gd
T

765 lines
37 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 外发)——零自动推进;放弃入口可见性归组合根(仅冒险模式开)。
## 内部数据类 _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)