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

606 lines
31 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
# 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_DONE # 已到——暗金(样张 v1 冻结新值 #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 # 候选通道/候选描边——烛光黄(M5:金色定岗只留当前位,G4;旧 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 NODE_TYPE_NAMES := {"battle": "战斗", "reward": "奖励", "rest": "休息", "boss": "Boss", "shop": "商店"} # M5 节点下方类型字(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 # 节点图标边长(样张 v1 冻结——20px 烘焙 1:1 消费零缩放)
const TITLE_ENTRY := "出发——选择首个节点" # 入口停点(布局决策 1——台账 143 遗留清偿)
const TITLE_SINGLE := "继续前进" # 单链停点
const TITLE_FORK := "选择下一节点" # 岔路停点
const NODE_HALF := Vector2(36.5, 25) # 节点方块半宽高(73×50 = 52×36 的 1.4×——UI 落码批 M5「黑底吃掉」;层距/布局公式零改)
const LAYER_BASE_X := 70 # 首层节点原点 x(画布拟合与布局公式共用——T4 杂并:双写退役)
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 # 迷你血条深底(样张 v1 冻结——对暗面板可辨;值单点收 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 # 特性持有行(迭代 12 呈现补齐——组合根投喂逐件文案〔2026-10-12 换代〕)
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 # 状态面板入口角标(迭代 17 卡③ P1 丁——顶栏 GP 徽右侧;连接归组合根)
var debug_badge: Button # 调试浮窗入口角标(迭代 18 卡⑦ P2 甲——DS_DEBUG 门控;状态角标同形制)
var _abandon_timer := ArmedTimer.new() # 放弃确认窗(T3/D3 P4 乙——_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)
# 节点图标贴图装载(样张 v1 冻结形制——load_tex 守卫,缺位回退现行字形)
for t in TEX_NODE_ICONS:
_node_icons[t] = UiKit.load_tex(TEX_NODE_ICONS[t])
# 独立屏底(形态升格 2026-09-28——spec Layout;UI 对齐批 T5 2026-10-06: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 常驻徽章——直角细线数据框;
# T5 判官 checkpoint 对齐 demo ④:关卡名入框板(裸文本退役)+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)
# 状态面板入口角标(迭代 17 卡③ P1 丁——顶栏常驻;debug 角标同形制:紧凑钮豁免宽度基准+字号降档。
# 置灰守卫归组合根 _sync_status_entry(其他模态开着=不可点;本屏自身的登记开态不计——has_open_excluding);
# 信号连接归组合根(ADR-0001——视图零连接))
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)
# 调试浮窗入口角标(迭代 18 卡⑦ P2 甲——顶栏状态角标右侧;17卡③同形制:紧凑钮豁免宽度基准+
# 字号降档。可见性/置灰归组合根 _sync_status_entry(DS_DEBUG 门控/其他模态开着=置灰——排除式
# 开态查询,本屏自身开态不计);信号连接归组合根(ADR-0001——视图零连接))
debug_badge = UiKit.button("调试", 0.0)
debug_badge.add_theme_font_size_override("font_size", UiKit.FS_HINT)
debug_badge.tooltip_text = "打开调试浮窗(当局数据/种子/数值调整)"
debug_badge.size_flags_vertical = Control.SIZE_SHRINK_CENTER
top.add_child(debug_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)
# 底部状态栏(M5 合并单条:亮米面板一站读齐=头像+生命+属性+本关加成/流派件——demo ④;
# 道具槽不摆=道具读数无关卡画面成文出口(读快照私域越权),随 runner 出口批补——台账 352 注记;
# _hp_value/_attr_value/_bonus_value/_feat_value 投喂键全部保留)
# 底栏定高纪律(2026-10-06 审计差距①对齐 demo——单条 ~85px 紧凑亮条;任何子件不再能把栏撑爆:
# 特性持有行限宽截断+tooltip 全文,旧「autowrap 无限宽→最小高度逐字竖排爆栏」形态根除)
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)
# P6 甲⑤(2026-10-06):定高 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)+ 流派件持有行(迭代 12 呈现补齐——组合根投喂)
_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)
# 特性持有行(迭代 12 呈现补齐 2026-10-04——用户反馈关卡选择页看不到局内奖励;组合根投喂)
# 审计差距①修(2026-10-06):限宽单行截断+tooltip 全文(旧 autowrap 无限宽=爆栏元凶);
# 墨色 WARM_SOFT→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) # 审计差距①对齐 demo:小号危险钮 ~90×38(原 216 宽砖);打磨包 2026-09-29 文案 + M5 危险钮皮
abandon_btn.pressed.connect(_on_abandon_pressed)
abandon_btn.visible = false
strip.add_child(abandon_btn)
visible = false
# 竖分隔线(M5 底栏分组线——亮面板上低对比细线)
static func _vdiv() -> ColorRect:
var v := ColorRect.new()
v.color = UiKit.MAP_VDIV
v.custom_minimum_size = Vector2(1, 44)
return v
# _status_box 已退役(M5 底栏合并单条——demo ④;本函数随旧三暗框退役删除,投喂键保留于新底栏)
func _draw_hp_bar(bar: Control) -> void:
# 迷你血条(深底 UiKit.HP_BAR_BACK #28221C + 小人蓝填充——M5 改蓝:与战斗站桩血条同语言,
# 「蓝只说己方」§4.2;旧金填充退役=金只说收益定岗 G4)。比例 = 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)
# 放弃入口可见性(组合根在冒险启动时开、默认关——语义对象 = 整局冒险)
# 特性持有投影(迭代 12 呈现补齐——组合根投喂逐件文案〔2026-10-12 换代〕;空文案=行隐藏)
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 = "放弃" # 打磨包 2026-09-29——文案收敛 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) -> 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 # 入口停点(尚未走出第一步——台账 143 措辞矛盾清偿)
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 同源;
# 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(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 = 类型文字标签行(M5)
_content_size = _content_size.max(Vector2(620, 256)) # 下限(M5 抬高容纳标签行)
_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 保持(T5)
# 状态栏刷新(命令式纪律——组合根打开本屏/兑付确认回执后重查;零推送信号订阅)
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[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 = _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()
# 连线(通道)先行画(方块覆盖其上);悬停预承诺 = 所属通道再增亮(加粗)
# T17 修复:选路停点(候选非空)非候选通道降透明——金岔路从结构网分离(干线共线重叠缺陷的
# 视觉主因:不同源通道共用同一垂直干线,金边与石色边焊接成「整列可走」假象)
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) # 样张 v1 冻结:2/3/4(原 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":
# Boss 描边 = 紫赤 2px(§4.2 色相独占)+ 外缘 1px 深墨环(灰度冗余通道——样张 v1 L2 逼守:
# 紫赤灰度 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)
# 节点图标(样张 v1 冻结形制:20px 烘焙 1:1 消费;双色墨制=乘法调制——当前金底深墨,暗底系 WARM 原色;
# 贴图缺位 → 字形回退=贴图在位字形退役惯例)
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)
# 编号(右上角——与图标占位区零重叠,样张 v1 L7;双色墨随态)
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)
# 类型文字标签(M5——demo ④:节点下方 11px;当前位=「当前位置」金、候选=类型名+「 · 可选」烛光黄、
# 其余=类型名暖米弱化;文字+图标+形状三通道)
var type_text := String(NODE_TYPE_NAMES.get(t, t))
if nid == current_id:
type_text = "当前位置"
elif cand_ids.has(nid):
type_text += " · 可选"
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),
type_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
# ---- 探针只读出口(D2 改造 2026-10-04——_layout/_canvas 私有域直入退役;形态仿 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}
# 分层(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 = 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
# T17 修复:垂直干线 x 按源节点 y 偏移——不同源的边不再共线重叠(旧 mid_x=中点使相邻层
# 全部通道焊成一条干系,「金岔路+整列可走」误读的直接几何根源)。同源双边仍共线(同出发
# 点语义正确),跨源分离度 = 源行距 × 0.2(L2 行距 ~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)