diff --git a/game/presentation/main_adventure_flow.gd b/game/presentation/main_adventure_flow.gd index e6aa68b9..e71d797f 100644 --- a/game/presentation/main_adventure_flow.gd +++ b/game/presentation/main_adventure_flow.gd @@ -120,6 +120,7 @@ static func _on_battle_end_continue(root: Variant) -> void: _enter_level_screen(root) root.level_screen.set_feat_holdings(" · ".join(HudPresenter.feat_holdings_lines(root.db, root.run_scope.runner.feats_held()))) root.level_screen.open_choices(root.run_scope.pending_candidates, root.run_scope.runner) + root.level_screen.apply_status(level_map_status_payload(root.run_scope.runner)) root.run_scope.pending_candidates = [] root._refresh_presentations() return @@ -171,6 +172,7 @@ static func _on_choices_presented(root: Variant, candidates: Array) -> void: _enter_level_screen(root) root.level_screen.set_feat_holdings(" · ".join(HudPresenter.feat_holdings_lines(root.db, root.run_scope.runner.feats_held()))) root.level_screen.open_choices(candidates, root.run_scope.runner) + root.level_screen.apply_status(level_map_status_payload(root.run_scope.runner)) root._refresh_presentations() static func _on_route_chosen(root: Variant, node_id: int) -> void: @@ -198,6 +200,36 @@ static func _exit_level_screen(root: Variant) -> void: root._close_modal(root.level_screen) root._sync_status_entry() # 同上 + +# 选路屏状态栏装配(视图零核心态拼装——architecture §1.3:读核心态→拼展示文案集中于装配器)。 +# 打开本屏/兑付确认回执后由组合根取成品喂入 apply_status(刷新时点与原视图内自刷一致)。 +static func level_map_status_payload(runner: LevelRunner) -> Dictionary: + var atk_bonus := int(runner.run_ledger.bonus.get("atk", 0)) # 加算区住 RunLedger + var ac_bonus := int(runner.run_ledger.bonus.get("armor_class", 0)) + var hp_bonus := int(runner.run_ledger.bonus.get("max_hp", 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 + 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) + return { + "level_label": String(runner.level_row.get("display_name", "")), + "gp_text": "◈ %d" % int(runner.gp), + "hp_text": "HP %d/%d" % [int(runner.hero_hp), int(runner.effective_max_hp())], + "hp_bar_redraw": true, + "attr_text": "攻击 %s · 护甲 AC %s" % [atk_txt, ac_txt], + "bonus_text": " · ".join(parts), + "bonus_row_visible": not parts.is_empty(), + } + static func _on_abandon_requested(root: Variant) -> void: # 放弃入口(冒险结构.md 已放开「MVP 无界面入口」后置条款; # 入口挂选路停点 = 天然非战斗期,拒绝分支仅防御在案):受理 → 冒险放弃结算(同失败型, diff --git a/game/presentation/views/level_map_screen.gd b/game/presentation/views/level_map_screen.gd index bd95e641..65a53761 100644 --- a/game/presentation/views/level_map_screen.gd +++ b/game/presentation/views/level_map_screen.gd @@ -52,6 +52,7 @@ var _drawn_disp: Dictionary[int, Dictionary] = {} # 本停点实际绘制记 var _node_icons := {} # 节点图标贴图(load_tex 守卫——缺位 null → 字形回退,冒烟/无素材检出安全) var _noise_rect: TextureRect # 屏底石纹(换层色板渐变承载面——null = 纯色回退无挂点) var _tint_level := 0 # 当前已应用 tint 的层号(0 = 未应用) +var _layout_cache := {"nodes": [], "canvas_size": Vector2.ZERO, "pos": {}} # 布局缓存(_layout 纯函数于 (nodes, canvas)——键=nodes 内容等价+画布尺寸,图态换代或画布尺寸变即自然失效) func _init() -> void: @@ -274,7 +275,6 @@ func open_choices(cands: Array, runner: LevelRunner) -> void: 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: @@ -322,35 +322,17 @@ func _apply_level_tint(level_index: int) -> void: Color(target.r, target.g, target.b, UiKit.BACKDROP_TILE_ALPHA), UiKit.motion(0.45)) # 0.14 弱质感档随 tint 保持 -# 状态栏刷新(命令式纪律——组合根打开本屏/兑付确认回执后重查;零推送信号订阅) -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.run_ledger.bonus.get("atk", 0)) # 加算区住 RunLedger - var ac_bonus := int(_runner.run_ledger.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.run_ledger.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) +# 状态栏投喂(命令式纪律——组合根经 level_map_status_payload 取成品后喂入;零推送信号订阅、零核心态拼装) +func apply_status(payload: Dictionary) -> void: + _level_label.text = String(payload["level_label"]) + _gp_value.text = String(payload["gp_text"]) + _hp_value.text = String(payload["hp_text"]) + if bool(payload["hp_bar_redraw"]): + _hp_bar.queue_redraw() + _attr_value.text = String(payload["attr_text"]) + _bonus_value.text = String(payload["bonus_text"]) # 特性持有行可见性(与加成行并框——任一非空整框显示;文案归组合根 set_feat_holdings) - _bonus_row.visible = not parts.is_empty() or _feat_value.text != "" + _bonus_row.visible = bool(payload["bonus_row_visible"]) or _feat_value.text != "" # ---- 图上命中(布局决策 2——唯一提交通道;探针 press_candidate 同路径) ---- @@ -444,6 +426,7 @@ func _draw_graph(canvas: Control) -> void: _drawn_disp.clear() # 绘制记录随本帧重建——只反映实际发生的绘制(接线坏的判据面) var nodes: Array[Variant] = _runner.nodes() var pos := _layout(nodes, canvas) + var font := get_theme_default_font() # 绘制路径字体单次取用(三处 draw_string 共享——零语义差) var current_id: int = _runner.current_node_id() # 当前节点 id(单链=下标 / 图=编号)——visited_ids() 已按编号空间解析 var visited: Array[Variant] = _runner.visited_ids() @@ -499,12 +482,11 @@ func _draw_graph(canvas: Control) -> void: 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) # 编号(右上角——与图标占位区零重叠;双色墨随态) 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), + canvas.draw_string(font, Vector2(rect.end.x - 16.0, rect.position.y + 8.0), str(nid), HORIZONTAL_ALIGNMENT_RIGHT, 14, 10, num_ink) # 类型文字标签(demo ④:节点下方 11px;当前位=「当前位置」金、候选=类型名+「 · 可选」烛光黄、 # 其余=类型名暖米弱化;文字+图标+形状三通道) @@ -512,12 +494,16 @@ func _draw_graph(canvas: Control) -> void: rec["draw_label"] = true rec["label_text"] = String(disp["label_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), + canvas.draw_string(font, Vector2(p.x - 44.0, rect.end.y + 15.0), String(disp["label_text"]), HORIZONTAL_ALIGNMENT_CENTER, 88, 11, type_ink) _drawn_disp[nid] = rec 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) @@ -526,6 +512,9 @@ func _layout(nodes: Array, canvas: Control) -> Dictionary: 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 diff --git a/game/tests/golden/README.md b/game/tests/golden/README.md index d32fd5d1..658d3a9d 100644 --- a/game/tests/golden/README.md +++ b/game/tests/golden/README.md @@ -1,7 +1,8 @@ # 视觉 golden 基线(渲染闸的比对基准) -四张基线:棋盘三图(`board_L1_dungeon` / `board_L2_underdark` / `board_L3_deep_underdark`, -裁剪区 900×760)+ 启动态菜单(`menu_boot`,全屏)。比对判据与工具见 `tools/golden_compare.py` +五张基线:棋盘三图(`board_L1_dungeon` / `board_L2_underdark` / `board_L3_deep_underdark`, +裁剪区 900×760)+ 启动态菜单(`menu_boot`,全屏)+ 关卡画面(`level_map`,全屏)。 +比对判据与工具见 `tools/golden_compare.py` (逐像素灰度差 >16 的像素占比 ≤2%)。 ## 时效判定纪律(迭代 29 T7 立) @@ -27,3 +28,8 @@ 字形——晚于 2026-10-08 建基提交,与本批零因果。呈报后用户裁「重制三张基线」, 经 `tools/golden_baseline_refresh.sh` 换代;复闸 changed = 0.00% / 0.00% / 0.00%, 启动态菜单 0.00%(未重制)。 +- 2026-10-26(迭代 38 卡① l4):**新增基线 `level_map`**(改前建基——时点铁序)。 + 来源 = shot_probe `_snap("level")` 帧(入口停点出图后第 4 帧,淡入完成态), + 跨跑次逐像素一致性实测 changed=0.00% 后落盘。承载迭代 38 卡① l1–l3 结构重构的 + 逐像素对拍(改后对拍 changed=0.00% 为验收主锚);对拍经 `tools/golden_compare.py` + 直跑(render_gate 现行只对拍棋盘三图+menu_boot,闸件不在本批改动面)。 diff --git a/game/tests/golden/level_map.png b/game/tests/golden/level_map.png new file mode 100644 index 00000000..01135c41 Binary files /dev/null and b/game/tests/golden/level_map.png differ diff --git a/game/tests/unit/presentation/level_map_info_fog_test.gd b/game/tests/unit/presentation/level_map_info_fog_test.gd index 56caa3b0..7fd70b0c 100644 --- a/game/tests/unit/presentation/level_map_info_fog_test.gd +++ b/game/tests/unit/presentation/level_map_info_fog_test.gd @@ -26,6 +26,7 @@ func _run() -> void: _test_details_never_shown() _test_label_three_states() await _test_big_graph_fit(db) + await _test_layout_cache(db) quit(Kit.finish("level_map_info_fog_test")) @@ -189,3 +190,38 @@ func _test_big_graph_fit(db) -> void: and bool(disp[boss_ids[0]]["boss_border"]) and bool(disp[boss_ids[1]]["boss_border"])) Kit.check("大图呈现:入口停点远处战斗节点(句柄 32,层 6)类型入雾〔fv8〕", not bool(disp[32]["draw_icon"])) screen.queue_free() + + +# ---- 布局缓存命中与换代失效〔levelmap批 W2〕---- +# 真实入口 = open_choices 后的真实绘制路径(_draw_graph→_layout,与 hover→_candidate_at 链同一缓存层); +# 命中读数 = is_same 同实例判别(重算必产新字典、命中返回缓存原实例)——零生产计数器。 + +func _test_layout_cache(db) -> void: + var nodes := _big_graph_nodes() + var runner = _runner(db, nodes, [nodes[1]]) + var screen = ScreenScript.new() + root.add_child(screen) + screen.open_choices([nodes[1]], runner) + await process_frame + await process_frame + var p1: Dictionary = screen.layout_probe(nodes) + var p2: Dictionary = screen.layout_probe(nodes) + Kit.check("布局缓存:同 nodes 两次查询返回同一实例(第二次命中缓存)〔levelmap批 V1〕", + is_same(p1, p2)) + # 换代失效:节点集内容变 → 缓存键(内容等价)击穿 → 重算换写。 + # 判别式 = 同一性:若缓存未随换代失效,新图查询将命中旧缓存返回同实例;重算必产新字典。 + # (不回查旧图——换代后 _content_size 已随 open_choices 重拟合到新图,旧图混查属生产外用法。) + var chain: Array = [ + {"id": 0, "type": "battle", "depth": 0, "next": [1]}, + {"id": 1, "type": "boss", "depth": 1, "next": []}, + ] + runner.level_row["nodes"] = chain + screen.open_choices([chain[0]], runner) + await process_frame + await process_frame # 换代图真实绘制路径重算(缓存随内容变失效重写) + var pc1: Dictionary = screen.layout_probe(chain) + var pc2: Dictionary = screen.layout_probe(chain) + Kit.check("布局缓存换代:新图查询返回新实例非旧缓存(失效重算生效)〔levelmap批 V2〕", + not is_same(p1, pc1) and is_same(pc1, pc2) + and (pc1[chain[0]] as Vector2) != (p1[nodes[0]] as Vector2)) + screen.queue_free()