From 4b5e3f27e81fe3a206ee8425bdb5392af076b766 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sun, 1 Nov 2026 17:27:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=A1=E2=91=A3T2(=E8=BF=AD=E4=BB=A347=C2=B7?= =?UTF-8?q?=E8=90=A5=E5=9C=B0=E5=9C=BA=E6=99=AF=E5=9B=BE=E6=89=B9):=20Camp?= =?UTF-8?q?SceneView=E5=9C=BA=E6=99=AF=E8=A7=86=E5=9B=BE+camp=5Fscene=5Fla?= =?UTF-8?q?yout.tres=E5=B8=83=E5=B1=80=E8=A1=A8+V14=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=9C=BA=E6=99=AF=E8=A7=86=E5=9B=BE/?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E5=9D=90=E6=A0=87/V14=E4=B8=89=E9=9D=A2?= =?UTF-8?q?=E8=90=BD=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schema两件: CampSceneLayerSpec(层键+贴图矩形+命中矩形+命中键)+CampSceneLayoutTable(canvas/hud_band_h/gate_gap/wall_band/layers+validate()按面拆四查:身份贴图/命中边界/命中互斥/围墙带包络) - 数据表camp_scene_layout.tres: 10层坐标唯一事实源(布局草案值+§5已裁:布告板(845,268,130,140)/门扇(520,250,240,220)门洞对齐/帐篷300x170;贴图矩形=contain终稿实测尺寸锚定:底中心/顶锚/底锚按物件语义,campfire命中(560,640,160,80)只取下半) - ConfigDB接线: 表注册+表现域容器camp_scene_layout字段+装载期validate()逐问题_fatal(fail-fast红线:命中区越画面/相交/侵顶栏=数据致命) - CampSceneView: setup_layers/bind_hotspots/get_hotspot_rect/set_feedback_state/set_hud_band五公开口+object_pressed信号; Control根+TextureRect层子节点序=z序(禁CanvasLayer/z_index/Sprite2D); 根单点_gui_input命中判定(层全IGNORE); 四态反馈:悬停=Candle轮廓+名牌(FS_HINT/INK_ON_DEEP/BG_ROOT板,画面内钳制)+Tween逐级整像素浮动/按下=位移1px/禁用=ShaderMaterial去饱和+降明度(零新素材,modulate只有明度轴); 素材缺位程序化占位冒烟安全; 命中键=七物件绑定(gate/forge/dummy/chest/notebook/board/campfire) - V14单测camp_scene_layout_test.gd(11断言): 校验函数通过/七键集/两两不相交/不越画面不侵顶栏/篝火只下半/门扇对齐缺口/布告板挂墙带/10层序/ConfigDB装载链——全表关系谓词零坐标字面量; run_all manifest登记expect=0 - 验证: --editor --quit刷类缓存后V14孤跑绿(11项)+视图孤检绿(9项:子节点序/合成点击信号/悬停名牌/拖出取消/禁用不发/HUD槽——临时件不入库,T5探针承载正式验收); S9/S13归基线(gdstyle新违规4条已按职责轴拆函数清偿:validate按面四拆/_gui_input三拆/setup_layers构建段三拆/循环内分配提_build_layer_node) --- game/core/config_db.gd | 6 +- game/core/config_domain_presentation.gd | 3 +- game/data/schemas/camp_scene_layer_spec.gd | 13 + .../data/schemas/camp_scene_layer_spec.gd.uid | 1 + game/data/schemas/camp_scene_layout_table.gd | 67 ++++ .../schemas/camp_scene_layout_table.gd.uid | 1 + game/data/tables/camp_scene_layout.tres | 82 +++++ game/presentation/views/camp_scene_view.gd | 303 ++++++++++++++++++ .../presentation/views/camp_scene_view.gd.uid | 1 + game/tests/run_all.gd | 1 + .../presentation/camp_scene_layout_test.gd | 89 +++++ .../camp_scene_layout_test.gd.uid | 1 + 12 files changed, 566 insertions(+), 2 deletions(-) create mode 100644 game/data/schemas/camp_scene_layer_spec.gd create mode 100644 game/data/schemas/camp_scene_layer_spec.gd.uid create mode 100644 game/data/schemas/camp_scene_layout_table.gd create mode 100644 game/data/schemas/camp_scene_layout_table.gd.uid create mode 100644 game/data/tables/camp_scene_layout.tres create mode 100644 game/presentation/views/camp_scene_view.gd create mode 100644 game/presentation/views/camp_scene_view.gd.uid create mode 100644 game/tests/unit/presentation/camp_scene_layout_test.gd create mode 100644 game/tests/unit/presentation/camp_scene_layout_test.gd.uid diff --git a/game/core/config_db.gd b/game/core/config_db.gd index 4a6d4f8d..de14a9b3 100644 --- a/game/core/config_db.gd +++ b/game/core/config_db.gd @@ -30,12 +30,13 @@ const TABLE_MANIFEST := { "feats": "feats.tres", # FeatTable 规则改写型特性(定义表 Rule 4 豁免——TreasureItemTable 先例;空表合法态=主时间线零扰动) "forge": "forge.tres", # ForgeTable——两根类型轴默认值+六路线行族;攻防所有权切分唯一数据面 "battle_layout": "battle_layout.tres", # BattleLayoutTable——战斗屏分区几何与明细带高度单行 + "camp_scene_layout": "camp_scene_layout.tres", # CampSceneLayoutTable——营地场景 10 层贴图/命中矩形与门洞缺口单行 } var combat: ConfigDomainCombat # 战斗编排域七表(board_gen/variation/status_effects/enemy/skill/hero/settlement) var run: ConfigDomainRun # 冒险 runner 域七表(level/difficulty/level_gen_params/reward/adventure/action_economy/shop) var crossrun: ConfigDomainCrossrun # 跨局元进度域五表(meta/meta_tree/treasure_items/feats/forge) -var presentation: ConfigDomainPresentation # 表现层布局域一表(battle_layout) +var presentation: ConfigDomainPresentation # 表现层布局域两表(battle_layout/camp_scene_layout) var is_ready := false var _diagnostics: Array[Dictionary] = [] # {severity, category, message} @@ -149,6 +150,9 @@ func _assign_loaded(loaded: Dictionary) -> void: crossrun.feats = loaded.get("feats") crossrun.forge = loaded.get("forge") presentation.battle_layout = loaded.get("battle_layout") + presentation.camp_scene_layout = loaded.get("camp_scene_layout") + for problem: String in presentation.camp_scene_layout.validate(): + _fatal("camp_scene_layout", problem) # 布局几何红线(命中区越画面/相交/侵顶栏)=数据致命 # resolver 协作件注入三表(resolve 委托承载——resolver 文件与键零搬移) combat.board_gen._resolver = _resolver combat.enemy._resolver = _resolver diff --git a/game/core/config_domain_presentation.gd b/game/core/config_domain_presentation.gd index b3cf13b3..3846979d 100644 --- a/game/core/config_domain_presentation.gd +++ b/game/core/config_domain_presentation.gd @@ -1,6 +1,7 @@ -## 表现层布局域容器(D2 域容器——config_db 表持有按域收敛):战斗屏布局一表类型化持有。 +## 表现层布局域容器(D2 域容器——config_db 表持有按域收敛):战斗屏与营地场景布局两表类型化持有。 ## 零方法零行为——纯持有结构;「经 ConfigDB 单点读取」=经其持有的容器与表对象(架构 :88 定稿注记)。 class_name ConfigDomainPresentation extends Resource var battle_layout: BattleLayoutTable +var camp_scene_layout: CampSceneLayoutTable diff --git a/game/data/schemas/camp_scene_layer_spec.gd b/game/data/schemas/camp_scene_layer_spec.gd new file mode 100644 index 00000000..152b8621 --- /dev/null +++ b/game/data/schemas/camp_scene_layer_spec.gd @@ -0,0 +1,13 @@ +## 营地场景单层规格(camp_scene_layout.tres 行件):层键+贴图矩形+命中矩形+命中键。 +## z 序=表内数组序(视图子节点序同源派生);坐标一律终稿画布 1280×800 像素坐标。 +class_name CampSceneLayerSpec +extends Resource + +@export var key: StringName = &"" # 层键(对齐 UiKit.TEX_CAMP_* 常量后缀) +@export var texture_rect: Rect2 = Rect2() # 贴图矩形(1:1 像素贴图落位) +@export var hotspot: Rect2 = Rect2() # 命中矩形(零矩形=不可点层——bg/wall/tent) +@export var hotspot_key: StringName = &"" # object_pressed 载荷键(可点层必填) + + +func is_clickable() -> bool: + return hotspot.size.x > 0.0 and hotspot.size.y > 0.0 diff --git a/game/data/schemas/camp_scene_layer_spec.gd.uid b/game/data/schemas/camp_scene_layer_spec.gd.uid new file mode 100644 index 00000000..2234c8c1 --- /dev/null +++ b/game/data/schemas/camp_scene_layer_spec.gd.uid @@ -0,0 +1 @@ +uid://bll8d0s1nmarf diff --git a/game/data/schemas/camp_scene_layout_table.gd b/game/data/schemas/camp_scene_layout_table.gd new file mode 100644 index 00000000..2137967d --- /dev/null +++ b/game/data/schemas/camp_scene_layout_table.gd @@ -0,0 +1,67 @@ +## 营地场景布局表 schema(camp_scene_layout.tres):10 层贴图矩形/命中矩形/门洞缺口/围墙带包络/顶栏禁区。 +## 坐标数值唯一事实源=数据行本表——视图、探针、单测一律读表取值,禁另写坐标字面量。 +class_name CampSceneLayoutTable +extends Resource + +@export var canvas := Vector2(1280, 800) # 终稿画布(工程档同幅面) +@export var hud_band_h := 48 # 顶栏禁区高度(命中不侵) +@export var gate_gap := Vector2i(520, 760) # 门洞缺口 x 范围(透出背景层小路,禁填死) +@export var wall_band := Rect2(0, 240, 1280, 70) # 围墙带包络(门柱顶 240—桩底 310;围墙贴图须落在其内) +@export var layers: Array[CampSceneLayerSpec] = [] + + +## 加载期 fail-fast 校验(ConfigDB 装载即查,问题非空=数据致命)。 +## 校验面按件拆分:层身份与贴图矩形/命中区边界/命中区互斥/围墙带包络。 +func validate() -> Array[String]: + var problems: Array[String] = [] + var clickables: Array[CampSceneLayerSpec] = _collect_clickables(problems) + _check_hotspot_bounds(clickables, problems) + _check_hotspot_disjoint(clickables, problems) + _check_wall_band(problems) + return problems + + +func _collect_clickables(problems: Array[String]) -> Array[CampSceneLayerSpec]: + var seen: Dictionary[StringName, bool] = {} + var clickables: Array[CampSceneLayerSpec] = [] + for layer in layers: + if layer.key == &"": + problems.append("层键为空") + continue + if seen.has(layer.key): + problems.append("层键重复: %s" % layer.key) + seen[layer.key] = true + if _out_of_canvas(layer.texture_rect): + problems.append("贴图矩形越画面: %s %s" % [layer.key, layer.texture_rect]) + if layer.is_clickable(): + if layer.hotspot_key == &"": + problems.append("可点层缺命中键: %s" % layer.key) + clickables.append(layer) + elif layer.hotspot_key != &"": + problems.append("不可点层带命中键: %s" % layer.key) + return clickables + + +func _check_hotspot_bounds(clickables: Array[CampSceneLayerSpec], problems: Array[String]) -> void: + for h in clickables: + if h.hotspot.position.x < 0.0 or h.hotspot.position.y < hud_band_h \ + or _out_of_canvas(h.hotspot): + problems.append("命中区越画面或侵顶栏禁区: %s %s" % [h.key, h.hotspot]) + + +func _check_hotspot_disjoint(clickables: Array[CampSceneLayerSpec], problems: Array[String]) -> void: + for i in clickables.size(): + for j in range(i + 1, clickables.size()): + if clickables[i].hotspot.intersects(clickables[j].hotspot): + problems.append("命中区相交: %s × %s" % [clickables[i].key, clickables[j].key]) + + +func _check_wall_band(problems: Array[String]) -> void: + for layer in layers: + if layer.key == &"wall" and not wall_band.encloses(layer.texture_rect): + problems.append("围墙贴图越围墙带包络: %s" % layer.texture_rect) + + +func _out_of_canvas(rect: Rect2) -> bool: + return rect.position.x < 0.0 or rect.position.y < 0.0 \ + or rect.end.x > canvas.x or rect.end.y > canvas.y diff --git a/game/data/schemas/camp_scene_layout_table.gd.uid b/game/data/schemas/camp_scene_layout_table.gd.uid new file mode 100644 index 00000000..6740c88f --- /dev/null +++ b/game/data/schemas/camp_scene_layout_table.gd.uid @@ -0,0 +1 @@ +uid://c6p8wyvcc5upp diff --git a/game/data/tables/camp_scene_layout.tres b/game/data/tables/camp_scene_layout.tres new file mode 100644 index 00000000..c811e355 --- /dev/null +++ b/game/data/tables/camp_scene_layout.tres @@ -0,0 +1,82 @@ +[gd_resource type="Resource" script_class="CampSceneLayoutTable" load_steps=13 format=3] + +[ext_resource type="Script" path="res://data/schemas/camp_scene_layout_table.gd" id="1"] +[ext_resource type="Script" path="res://data/schemas/camp_scene_layer_spec.gd" id="2"] + +[sub_resource type="Resource" id="1"] +script = ExtResource("2") +key = &"bg" +texture_rect = Rect2(0, 0, 1280, 800) +hotspot = Rect2(0, 0, 0, 0) +hotspot_key = &"" + +[sub_resource type="Resource" id="2"] +script = ExtResource("2") +key = &"wall" +texture_rect = Rect2(0, 261, 1280, 49) +hotspot = Rect2(0, 0, 0, 0) +hotspot_key = &"" + +[sub_resource type="Resource" id="3"] +script = ExtResource("2") +key = &"board" +texture_rect = Rect2(845, 268, 130, 121) +hotspot = Rect2(845, 268, 130, 140) +hotspot_key = &"board" + +[sub_resource type="Resource" id="4"] +script = ExtResource("2") +key = &"gate" +texture_rect = Rect2(520, 250, 240, 211) +hotspot = Rect2(520, 250, 240, 220) +hotspot_key = &"gate" + +[sub_resource type="Resource" id="5"] +script = ExtResource("2") +key = &"dummy" +texture_rect = Rect2(340, 520, 120, 180) +hotspot = Rect2(340, 520, 120, 180) +hotspot_key = &"dummy" + +[sub_resource type="Resource" id="6"] +script = ExtResource("2") +key = &"forge" +texture_rect = Rect2(40, 467, 240, 193) +hotspot = Rect2(40, 420, 240, 240) +hotspot_key = &"forge" + +[sub_resource type="Resource" id="7"] +script = ExtResource("2") +key = &"tent" +texture_rect = Rect2(982, 520, 251, 170) +hotspot = Rect2(0, 0, 0, 0) +hotspot_key = &"" + +[sub_resource type="Resource" id="8"] +script = ExtResource("2") +key = &"chest" +texture_rect = Rect2(1097, 700, 106, 90) +hotspot = Rect2(1085, 700, 130, 90) +hotspot_key = &"chest" + +[sub_resource type="Resource" id="9"] +script = ExtResource("2") +key = &"notebook" +texture_rect = Rect2(818, 704, 100, 66) +hotspot = Rect2(818, 690, 100, 80) +hotspot_key = &"notebook" + +[sub_resource type="Resource" id="10"] +script = ExtResource("2") +key = &"campfire" +texture_rect = Rect2(560, 571, 160, 149) +hotspot = Rect2(560, 640, 160, 80) +hotspot_key = &"campfire" + +[resource] +script = ExtResource("1") +canvas = Vector2(1280, 800) +hud_band_h = 48 +gate_gap = Vector2i(520, 760) +wall_band = Rect2(0, 240, 1280, 70) +layers = [SubResource("1"), SubResource("2"), SubResource("3"), SubResource("4"), SubResource("5"), SubResource("6"), SubResource("7"), SubResource("8"), SubResource("9"), SubResource("10")] diff --git a/game/presentation/views/camp_scene_view.gd b/game/presentation/views/camp_scene_view.gd new file mode 100644 index 00000000..2019288c --- /dev/null +++ b/game/presentation/views/camp_scene_view.gd @@ -0,0 +1,303 @@ +## 营地场景图视图(交互式营地):10 层贴图铺底+七物件命中区+点选反馈四态+顶栏预留位。 +## 布局唯一事实源 = db.presentation.camp_scene_layout(构造注入,本视图禁自载表路径); +## 子节点序 = 表内数组序 = z 序(Control 根+TextureRect 层——禁 CanvasLayer/z_index/Sprite2D)。 +## 输入单点:根 _gui_input 集中命中判定(层节点全 IGNORE),点击只发 object_pressed 信号,路由归组合根。 +## 零核心写调用:视图不读 MetaProgression,禁用态由组合根经 set_feedback_state 推送。 +class_name CampSceneView +extends Control + +signal object_pressed(key: StringName) ## 命中区点击确认(press+release 同区且非禁用;组合根路由开界面/动线) + +## 名牌文案(camp.md §场景构成绑定表——七可点件按界面名逐字) +const _HOTSPOT_NAMES := { + &"gate": "冒险", &"forge": "锻造工坊", &"chest": "秘宝匣", &"dummy": "天赋树", + &"board": "奖励格图鉴", &"notebook": "生涯记录", &"campfire": "离开营地", +} +## 层键→纹理路径(单点 = UiKit.TEX_CAMP_*,本表只引用不自写路径;缺位 load_tex→null→程序化占位) +const _TEX_BY_KEY := { + &"bg": UiKit.TEX_CAMP_BG, &"wall": UiKit.TEX_CAMP_WALL, &"board": UiKit.TEX_CAMP_BOARD, + &"gate": UiKit.TEX_CAMP_GATE, &"dummy": UiKit.TEX_CAMP_DUMMY, &"forge": UiKit.TEX_CAMP_FORGE, + &"tent": UiKit.TEX_CAMP_TENT, &"chest": UiKit.TEX_CAMP_CHEST, + &"notebook": UiKit.TEX_CAMP_NOTEBOOK, &"campfire": UiKit.TEX_CAMP_CAMPFIRE, +} +## 禁用态去饱和着色器(modulate 只能调明度——降明度+去饱和走 canvas_item shader,零新素材) +const _DESAT_SHADER_SRC := " +shader_type canvas_item; +uniform float strength : hint_range(0.0, 1.0) = 1.0; +void fragment() { + vec4 tex = texture(TEXTURE, UV); + float g = dot(tex.rgb, vec3(0.299, 0.587, 0.114)); + tex.rgb = mix(tex.rgb, vec3(g) * 0.72, strength); + COLOR = tex * COLOR; +} +" + +enum FeedbackState { NORMAL, HOVER, PRESSED, DISABLED } ## 点选反馈四态(camp.md §点选反馈) + +var _layout: CampSceneLayoutTable # 布局表(坐标唯一事实源) +var _layer_nodes: Dictionary[StringName, Control] = {} # 层键→TextureRect/ColorRect 占位(子节点序=z 序) +var _hotspot_rects: Dictionary[StringName, Rect2] = {} # 命中键→Rect2(视图局部坐标=表坐标) +var _hotspot_layer: Dictionary[StringName, StringName] = {} # 命中键→层键 +var _base_states: Dictionary[StringName, int] = {} # 命中键→FeedbackState 值(持久基态——组合根置 DISABLED/NORMAL) +var _outline: Panel # 悬停轮廓亮边 +var _name_label: Label # 物件名牌(下方 8px 居中,画面内钳制) +var _hover_key := &"" # 当前悬停命中键(&""=无) +var _press_key := &"" # 当前按住命中键 +var _float_tween: Tween # 悬停浮动补间(逐级整像素吸附,禁 _process 逐帧移 UI) +var _desat_mat: ShaderMaterial # 禁用态共享材质(strength 恒 1,材质挂/摘承载开关) +var _hud_slot: Control # 顶栏预留位(卡⑤ set_hud_band 填值) + + +func setup_layers(layout: CampSceneLayoutTable) -> void: + if layout == null: + push_error("[CampSceneView] 布局表缺位——组合根须注入 db.presentation.camp_scene_layout") + return + _layout = layout + mouse_filter = Control.MOUSE_FILTER_STOP # 场景图=营地语境主交互面(模态层在兄弟位先截事件) + _build_desat_material() + for spec in layout.layers: + var node: Control = _build_layer_node(spec) + add_child(node) + _layer_nodes[spec.key] = node + _build_outline() + _build_name_label() + _build_hud_slot(layout) + mouse_exited.connect(_on_mouse_exited) # 悬出根控件=清 hover/press(防态悬挂) + + +func bind_hotspots(layout: CampSceneLayoutTable) -> void: + if layout == null: + return + for spec in layout.layers: + if spec.is_clickable(): + _hotspot_rects[spec.hotspot_key] = spec.hotspot + _hotspot_layer[spec.hotspot_key] = spec.key + _base_states[spec.hotspot_key] = FeedbackState.NORMAL + + +func get_hotspot_rect(key: StringName) -> Rect2: + var rect: Rect2 = _hotspot_rects.get(key, Rect2()) + return rect + + +func set_feedback_state(key: StringName, state: FeedbackState) -> void: + if not _base_states.has(key): + return + _base_states[key] = state # 持久基态只收 NORMAL/DISABLED;HOVER/PRESSED 为视图内瞬态 + _apply_state(key, _base_state(key)) + + +func set_hud_band(band: Control) -> void: + if _hud_slot != null: + _hud_slot.add_child(band) + + +func _gui_input(event: InputEvent) -> void: + if event is InputEventMouseMotion: + _handle_motion(event as InputEventMouseMotion) + elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: + _handle_button(event as InputEventMouseButton) + accept_event() + + +func _handle_motion(event: InputEventMouseMotion) -> void: + var key: StringName = _topmost_hotspot_at(event.position) + if key != _hover_key: + if _hover_key != &"": + _apply_state(_hover_key, _base_state(_hover_key)) + _hover_key = key + if key != &"": + _apply_state(key, FeedbackState.HOVER) + if _press_key != &"" and key != _press_key: + _apply_state(_press_key, _base_state(_press_key)) # 按住拖出=取消按压 + _press_key = &"" + + +func _handle_button(event: InputEventMouseButton) -> void: + var hit: StringName = _topmost_hotspot_at(event.position) + if event.pressed: + if hit != &"": + _press_key = hit + _apply_state(hit, FeedbackState.PRESSED) + return + if _press_key == &"": + return + if hit == _press_key and not _is_disabled(_press_key): + object_pressed.emit(_press_key) + _apply_state(_press_key, _base_state(_press_key)) + _press_key = &"" + + +func _build_layer_node(spec: CampSceneLayerSpec) -> Control: + var node: Control = null + var tex: Texture2D = UiKit.load_tex(_TEX_BY_KEY.get(spec.key, "")) + if tex != null: + var tr := TextureRect.new() + tr.texture = tex + tr.stretch_mode = TextureRect.STRETCH_SCALE + node = tr + else: + var ph := ColorRect.new() # 程序化占位(素材缺位=冷检出半同步态,结构在位冒烟安全) + ph.color = Color(1.0, 1.0, 1.0, 0.06) + node = ph + node.name = "Layer_%s" % spec.key + node.position = spec.texture_rect.position + node.size = spec.texture_rect.size + node.mouse_filter = Control.MOUSE_FILTER_IGNORE + return node + + +func _build_desat_material() -> void: + _desat_mat = ShaderMaterial.new() + var shader := Shader.new() + shader.code = _DESAT_SHADER_SRC + _desat_mat.shader = shader + + +func _build_outline() -> void: + _outline = Panel.new() + var sb := StyleBoxFlat.new() + sb.draw_center = false + sb.border_color = UiKit.CANDLE + sb.set_border_width_all(2) + sb.set_corner_radius_all(4) + _outline.add_theme_stylebox_override("panel", sb) + _outline.mouse_filter = Control.MOUSE_FILTER_IGNORE + _outline.visible = false + add_child(_outline) + + +func _build_name_label() -> void: + _name_label = UiKit.label("", UiKit.FS_HINT, false, UiKit.INK_ON_DEEP) + var plate := StyleBoxFlat.new() + plate.bg_color = UiKit.BG_ROOT + plate.set_corner_radius_all(4) + plate.content_margin_left = 8.0 + plate.content_margin_right = 8.0 + plate.content_margin_top = 2.0 + plate.content_margin_bottom = 2.0 + _name_label.add_theme_stylebox_override("normal", plate) + _name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE # 名牌不挡命中区 + _name_label.visible = false + add_child(_name_label) + + +func _build_hud_slot(layout: CampSceneLayoutTable) -> void: + _hud_slot = Control.new() + _hud_slot.name = "HudBandSlot" + _hud_slot.set_anchors_preset(Control.PRESET_TOP_WIDE) + _hud_slot.custom_minimum_size = Vector2(0, layout.hud_band_h) # 48px 浮层覆盖,不挤压画面 + _hud_slot.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_hud_slot) + + +func _topmost_hotspot_at(pos: Vector2) -> StringName: + if _layout == null: + return &"" + var idx: int = _layout.layers.size() - 1 # 逆序=自最上 z 向下 + while idx >= 0: + var spec: CampSceneLayerSpec = _layout.layers[idx] + if spec.is_clickable() and _hotspot_rects.get(spec.hotspot_key, Rect2()).has_point(pos): + return spec.hotspot_key + idx -= 1 + return &"" + + +func _base_state(key: StringName) -> FeedbackState: + var st: int = _base_states.get(key, FeedbackState.NORMAL) + return FeedbackState.DISABLED if st == FeedbackState.DISABLED else FeedbackState.NORMAL + + +func _is_disabled(key: StringName) -> bool: + return _base_state(key) == FeedbackState.DISABLED + + +func _apply_state(key: StringName, state: FeedbackState) -> void: + var layer_key: StringName = _hotspot_layer.get(key, &"") + var node: Control = _layer_nodes.get(layer_key, null) + if node == null: + return + var rect: Rect2 = _hotspot_rects.get(key, Rect2()) + var base_y: float = node.position.y - _float_offset_of(key) # 还原到基准位再施新态 + _set_float_offset(key, 0) + if _float_tween != null: + _float_tween.kill() + _float_tween = null + match state: + FeedbackState.NORMAL: + node.material = null + _outline.visible = false + _name_label.visible = false + FeedbackState.HOVER: + _apply_hover(key, node, rect) + FeedbackState.PRESSED: + node.position.y = base_y + 1.0 # 按下=位移 1px + FeedbackState.DISABLED: + _apply_disabled(node) + + +func _apply_hover(key: StringName, node: Control, rect: Rect2) -> void: + if _is_disabled(key): + _apply_disabled(node) + return + node.material = null + _outline.position = rect.position + _outline.size = rect.size + _outline.visible = true + _show_name_plate(key, rect) + _run_float(key) # 悬停浮动(整像素逐级吸附) + + +func _apply_disabled(node: Control) -> void: + node.material = _desat_mat # 降明度+去饱和(shader;仅锻炉/宝箱会有此态) + _outline.visible = false + _name_label.visible = false + + +func _run_float(key: StringName) -> void: + _float_tween = create_tween() + for step in 4: + var off := -1 - step + _float_tween.tween_callback(_set_float_offset.bind(key, off)).set_delay(UiKit.motion(0.03)) + # 悬离时由 _apply_state(NORMAL) 先归零位——本补间只承载升浮 + + +func _set_float_offset(key: StringName, off: int) -> void: + var layer_key: StringName = _hotspot_layer.get(key, &"") + var node: Control = _layer_nodes.get(layer_key, null) + if node == null or _layout == null: + return + for spec in _layout.layers: + if spec.key == layer_key: + node.position.y = spec.texture_rect.position.y + float(off) + return + + +func _float_offset_of(key: StringName) -> float: + var layer_key: StringName = _hotspot_layer.get(key, &"") + var node: Control = _layer_nodes.get(layer_key, null) + if node == null or _layout == null: + return 0.0 + for spec in _layout.layers: + if spec.key == layer_key: + return node.position.y - spec.texture_rect.position.y + return 0.0 + + +func _show_name_plate(key: StringName, rect: Rect2) -> void: + _name_label.text = String(_HOTSPOT_NAMES.get(key, "")) + _name_label.visible = true + _name_label.reset_size() + var pos := Vector2.ZERO + pos.x = clampf(rect.get_center().x - _name_label.size.x / 2.0, 0.0, _layout.canvas.x - _name_label.size.x) + pos.y = minf(rect.end.y + 8.0, _layout.canvas.y - _name_label.size.y) # 贴底物件防出画(画面内钳制) + _name_label.position = pos + + +func _on_mouse_exited() -> void: + if _hover_key != &"": + _apply_state(_hover_key, _base_state(_hover_key)) + _hover_key = &"" + if _press_key != &"": + _apply_state(_press_key, _base_state(_press_key)) + _press_key = &"" diff --git a/game/presentation/views/camp_scene_view.gd.uid b/game/presentation/views/camp_scene_view.gd.uid new file mode 100644 index 00000000..90ca8b4a --- /dev/null +++ b/game/presentation/views/camp_scene_view.gd.uid @@ -0,0 +1 @@ +uid://btsrctv5ddx16 diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index adc03204..447d1448 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -192,6 +192,7 @@ func _suites() -> Array: {"path": "res://tests/unit/presentation/reward_overlay_test.gd", "expect": 0}, # 奖励节点 UI 直验(盲区补漏) {"path": "res://tests/unit/presentation/level_map_info_fog_test.gd", "expect": 0}, # 信息迷雾——呈现显隐/Boss 豁免/详情零显示/大图拟合 {"path": "res://tests/unit/presentation/forge_workbench_test.gd", "expect": 0}, # 工作间两区六路线 UI 直验(V13 自动化前置面) + {"path": "res://tests/unit/presentation/camp_scene_layout_test.gd", "expect": 0}, # 营地场景布局数据件校验(V14——fail-fast 判据+七命中区几何红线) {"path": "res://tests/unit/battle_settlement/damage_math_test.gd", "expect": 0}, {"path": "res://tests/unit/battle_settlement/damage_controller_test.gd", "expect": 0}, {"path": "res://tests/unit/battle_settlement/roll_multiplier_test.gd", "expect": 0}, # 掷点与乘区宿主(敌攻击类型轴/护甲掷点行为面/乘区接线/伤害类型克制与标错/fresh 池攻击链——批 B 自 smoke_all 迁入) diff --git a/game/tests/unit/presentation/camp_scene_layout_test.gd b/game/tests/unit/presentation/camp_scene_layout_test.gd new file mode 100644 index 00000000..8605dc48 --- /dev/null +++ b/game/tests/unit/presentation/camp_scene_layout_test.gd @@ -0,0 +1,89 @@ +extends SceneTree +# 营地场景布局数据件校验套件(V14——camp_scene_layout.tres 加载期 fail-fast 判据+七命中区几何红线机检) +# 覆盖:校验函数通过(键唯一/贴图在画布/命中成对/围墙带包络)· 七可点件键集 · 两两不相交 · +# 不越画面 · 不侵顶栏禁区 · 篝火命中只取下半(关系谓词)· 门扇对齐门洞缺口 · 布告板挂围墙带。 +# 断言全部为表内关系谓词——禁坐标字面量(数值单一事实源 = 数据行本表)。 +# 运行:godot --headless --path game --script res://tests/unit/presentation/camp_scene_layout_test.gd(expect 0) +const Kit := preload("res://tests/unit/test_kit.gd") + +const _EXPECTED_KEYS: Array[StringName] = [&"gate", &"forge", &"dummy", &"chest", &"notebook", &"board", &"campfire"] + + +func _initialize() -> void: + call_deferred("_test_layout_hotspot_geometry") + + +func _test_layout_hotspot_geometry() -> void: + var table: CampSceneLayoutTable = load("res://data/tables/camp_scene_layout.tres") + Kit.check("V14 布局表加载且 schema 类在位〔营地图批 V14〕", table is CampSceneLayoutTable and table != null) + if table == null: + quit(Kit.finish("camp_scene_layout_test")) + return + var problems: Array[String] = table.validate() + Kit.check("V14 校验函数通过(问题清单空,实得 %s)〔营地图批 V14〕" % [problems], problems.is_empty()) + var clickables: Array[CampSceneLayerSpec] = [] + for spec in table.layers: + if spec.is_clickable(): + clickables.append(spec) + Kit.check("V14 可点件数=7(实得 %d)〔营地图批 V14〕" % clickables.size(), clickables.size() == 7) + var got_keys: Array[StringName] = [] + for spec in clickables: + got_keys.append(spec.hotspot_key) + got_keys.sort() + var want_keys: Array[StringName] = [] + for k in _EXPECTED_KEYS: + want_keys.append(k) + want_keys.sort() + Kit.check("V14 命中键集=七物件绑定表(实得 %s)〔营地图批 V14〕" % [got_keys], got_keys == want_keys) + # 两两不相交(Rect2.intersects——贴边相触不算相交) + var disjoint := true + var pair := "" + for i in clickables.size(): + for j in range(i + 1, clickables.size()): + if clickables[i].hotspot.intersects(clickables[j].hotspot): + disjoint = false + pair = "%s×%s" % [clickables[i].key, clickables[j].key] + Kit.check("V14 七命中区两两不相交(首违例 %s)〔营地图批 V14〕" % pair, disjoint) + # 不越画面+不侵顶栏禁区 + var in_bounds := true + var offender := "" + for spec in clickables: + if spec.hotspot.position.x < 0.0 or spec.hotspot.position.y < table.hud_band_h \ + or spec.hotspot.end.x > table.canvas.x or spec.hotspot.end.y > table.canvas.y: + in_bounds = false + offender = String(spec.key) + Kit.check("V14 命中区不越画面且不侵顶栏禁区(y≥%d,违例 %s)〔营地图批 V14〕" % [table.hud_band_h, offender], in_bounds) + # 篝火命中只取下半:命中区贴贴图底、严格小于贴图、起点在贴图内(火焰上段不可点) + var fire: CampSceneLayerSpec = null + var gate: CampSceneLayerSpec = null + var board: CampSceneLayerSpec = null + var wall: CampSceneLayerSpec = null + for spec in table.layers: + match String(spec.key): + "campfire": fire = spec + "gate": gate = spec + "board": board = spec + "wall": wall = spec + var fire_ok := fire != null and fire.hotspot.end.y == fire.texture_rect.end.y \ + and fire.hotspot.size.y < fire.texture_rect.size.y \ + and fire.hotspot.position.y > fire.texture_rect.position.y + Kit.check("V14 篝火命中只取下半(火堆贴底、火焰不点)〔营地图批 V14〕", fire_ok) + # 门扇命中对齐门洞缺口(表内 gate_gap 字段关系谓词) + var gate_ok := gate != null and gate.hotspot.position.x == float(table.gate_gap.x) \ + and gate.hotspot.end.x == float(table.gate_gap.y) + Kit.check("V14 门扇命中对齐门洞缺口 %s〔营地图批 V14〕" % [table.gate_gap], gate_ok) + # 布告板挂围墙带:命中顶落在围墙贴图带内 + var board_ok := board != null and wall != null \ + and board.hotspot.position.y >= wall.texture_rect.position.y \ + and board.hotspot.position.y <= wall.texture_rect.end.y + Kit.check("V14 布告板命中挂围墙带〔营地图批 V14〕", board_ok) + # 层序结构:10 层且 bg/wall 压前两位(z0/z1——子节点序=表序先验) + var order_ok := table.layers.size() == 10 \ + and String(table.layers[0].key) == "bg" and String(table.layers[1].key) == "wall" + Kit.check("V14 层数=10 且 bg/wall 占 z0/z1〔营地图批 V14〕", order_ok) + # ConfigDB 装载链:表经单点注册进表现域容器且装载期校验过(fail-fast 钩子在位) + var db = load("res://core/config_db.gd").new() + var load_ok: bool = db.load_tables("res://data/tables", true) + Kit.check("V14 ConfigDB 装载含营地布局表且校验过(is_ready=%s)〔营地图批 V14〕" % db.is_ready, + load_ok and db.is_ready and db.presentation.camp_scene_layout != null) + quit(Kit.finish("camp_scene_layout_test")) diff --git a/game/tests/unit/presentation/camp_scene_layout_test.gd.uid b/game/tests/unit/presentation/camp_scene_layout_test.gd.uid new file mode 100644 index 00000000..0ea0b2dd --- /dev/null +++ b/game/tests/unit/presentation/camp_scene_layout_test.gd.uid @@ -0,0 +1 @@ +uid://ckyd57l11408