Files
gplan-2/game/presentation/views/level_map_screen.gd
T
DKChan a055722372 feat(ux): T3 全链流程随独立屏——兑付覆盖层本屏域内弹出+出屏分支
_submit_choice 节点类型分支:战斗/Boss=出屏(visible=false 后发射);奖励/恢复/商店=本屏保持激活发射——兑付覆盖层由节点事件在本屏域内弹出(模态栈打开低层自动隐藏高层本屏、关闭自动恢复,零机制改动复用),兑付确认回图即本屏就地刷新(open_choices 幂等,was_visible 判据不重放淡入)。组合根 _on_route_chosen 改视图去留随动:出屏才收栈记录+_exit_level_screen 回战斗屏;放弃/冒险终局强制回战斗屏呈现摘要(T2 已落)。零自动推进纪律零改动。轻闸=冒烟 557/0+五探针 PASS(click_probe 岔路/商店路径含域内弹出全程实测)。
2026-09-28 06:42:39 +08:00

467 lines
20 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_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)