# W7 HUD —— 小地图(拼 minimap.dds)+ 状态栏 / 快捷栏 / 背包。 # §6.1:窗口/血条/快捷栏用真实 `ETC/ymir work/ui/pattern/` 九宫格贴图(AssetResolver 机制), # 精确布局仍待 §0 的 ingame-shinsoo-ui.json。SHINSOO §9-W7 / PARITY §6.1。 # 用法:var hud := preload("res://hud.gd").new(); hud.setup(world, player); add_child(hud) extends CanvasLayer var _world: Node var _player: Node3D var _minimap_rect: TextureRect var _minimap_frame: Control var _dot: ColorRect var _map_w_m := 1024.0 var _map_h_m := 1280.0 var _inv: Control var _assets: String var _pat := "ETC/ymir work/ui/pattern/" var _tex_cache := {} var _hp_fill: NinePatchRect var _mp_fill: NinePatchRect var _stamina_fill: ColorRect var _stamina_label: Label var _exp_fill: ColorRect var _lvl_label: Label var _hp_label: Label var _hp_w := 240.0 var _energy_fill: ColorRect var _energy_label: Label var _status_box: VBoxContainer var quickbar: Node: set(v): quickbar = v if quickbar: _sync_vitals_to_quickbar() var _cached_hp: int = 0 var _cached_max_hp: int = 0 var _cached_sp: int = 0 var _cached_max_sp: int = 0 var _cached_st: int = 0 var _cached_max_st: int = 0 var _cached_exp: int = 0 var _cached_next_exp: int = 0 func _sync_vitals_to_quickbar() -> void: if not quickbar: return if _cached_max_hp > 0 and quickbar.has_method("set_hp"): quickbar.set_hp(_cached_hp, _cached_max_hp) if _cached_max_sp > 0 and quickbar.has_method("set_sp"): quickbar.set_sp(_cached_sp, _cached_max_sp) if _cached_max_st > 0 and quickbar.has_method("set_st"): quickbar.set_st(_cached_st, _cached_max_st) if _cached_next_exp > 0 and quickbar.has_method("set_exp"): quickbar.set_exp(_cached_exp, _cached_next_exp) # 目标血条(P0) var _target_panel: Control var _target_name: Label var _target_fill: ColorRect # 状态图标条(P4) var _affect_row: HBoxContainer # GC_DUNGEON destination compass (ClientVS22: AlarmHaveToGo). var _dungeon_panel: Control var _dungeon_label: Label var _dungeon_arrow: Label var _dungeon_destination := Vector3.ZERO func setup(world: Node, player: Node3D) -> void: _world = world _player = player _assets = String(world.get("assets_root")) var rep: Dictionary = _world.call("get_load_report") _map_w_m = float(rep["map_size_x"]) * 256.0 _map_h_m = float(rep["map_size_y"]) * 256.0 _build_minimap(int(rep["map_size_x"]), int(rep["map_size_y"])) _build_status() # 技能快捷栏由 ui/quickbar.gd 管理:它恢复服务端的 36 个槽位、显示 # 技能图标并处理冷却。这里以前画的静态空槽会和它重叠,让实际技能看起来 # 没有加载,因此 HUD 不再创建第二条快捷栏。 _build_inventory() _build_dungeon_compass() func reload_world(new_world: Node) -> void: _world = new_world if _world == null: return var rep: Dictionary = _world.call("get_load_report") _map_w_m = float(rep.get("map_size_x", 1)) * 256.0 _map_h_m = float(rep.get("map_size_y", 1)) * 256.0 if _minimap_frame and is_instance_valid(_minimap_frame): _minimap_frame.queue_free() _minimap_frame = null _minimap_rect = null _dot = null if int(rep.get("map_size_x", 0)) > 0 and int(rep.get("map_size_y", 0)) > 0: _build_minimap(int(rep["map_size_x"]), int(rep["map_size_y"])) # --- real UI texture loading (§6.1) ------------------------------------------- func _ui_tex(name: String) -> Texture2D: if _tex_cache.has(name): return _tex_cache[name] var img := Image.new() var tex: Texture2D = null var cand := _assets.path_join(_pat + name + ".tga") if not FileAccess.file_exists(cand): # fall back: scan asset packs for "/ymir work/ui/pattern/.tga" var da := DirAccess.open(_assets) if da: for sub in da.get_directories(): var p := _assets.path_join(sub).path_join("ymir work/ui/pattern/" + name + ".tga") if FileAccess.file_exists(p): cand = p break if FileAccess.file_exists(cand) and img.load(cand) == OK: tex = ImageTexture.create_from_image(img) _tex_cache[name] = tex return tex # Composite a set of pattern pieces into one atlas and return a NinePatchRect. # pieces = {corner_lt, corner_rt, corner_lb, corner_rb, line_t, line_l, base} func _nine(prefix: String, m: int, base_size: int) -> NinePatchRect: var total := m + base_size + m var atlas := Image.create_empty(total, total, false, Image.FORMAT_RGBA8) atlas.fill(Color(0, 0, 0, 0)) var put := func(name: String, x: int, y: int) -> void: var t := _ui_tex(name) if t == null: return var im := t.get_image() im.convert(Image.FORMAT_RGBA8) atlas.blit_rect(im, Rect2i(Vector2i.ZERO, im.get_size()), Vector2i(x, y)) put.call(prefix + "_corner_lefttop", 0, 0) put.call(prefix + "_corner_righttop", m + base_size, 0) put.call(prefix + "_corner_leftbottom", 0, m + base_size) put.call(prefix + "_corner_rightbottom", m + base_size, m + base_size) # edges: stretch the piece across the middle band var edge := func(name: String, r: Rect2i) -> void: var t := _ui_tex(name) if t == null: return var im := t.get_image() im.convert(Image.FORMAT_RGBA8) im.resize(r.size.x, r.size.y, Image.INTERPOLATE_BILINEAR) atlas.blit_rect(im, Rect2i(Vector2i.ZERO, r.size), r.position) edge.call(prefix + "_line_top", Rect2i(m, 0, base_size, m)) edge.call(prefix + "_line_bottom", Rect2i(m, m + base_size, base_size, m)) edge.call(prefix + "_line_left", Rect2i(0, m, m, base_size)) edge.call(prefix + "_line_right", Rect2i(m + base_size, m, m, base_size)) var b := _ui_tex(prefix + "_base") if b: var bi := b.get_image() bi.convert(Image.FORMAT_RGBA8) bi.resize(base_size, base_size, Image.INTERPOLATE_BILINEAR) atlas.blit_rect(bi, Rect2i(Vector2i.ZERO, Vector2i(base_size, base_size)), Vector2i(m, m)) var np := NinePatchRect.new() np.texture = ImageTexture.create_from_image(atlas) np.patch_margin_left = m np.patch_margin_right = m np.patch_margin_top = m np.patch_margin_bottom = m return np # horizontal 3-slice gauge (left cap | tiled centre | right cap) as a NinePatchRect func _gauge(center_name: String, h: int) -> NinePatchRect: var pieces := [center_name + "_left" if _ui_tex(center_name + "_left") else center_name, center_name + "_center" if _ui_tex(center_name + "_center") else center_name, center_name + "_right" if _ui_tex(center_name + "_right") else center_name] var seg := 16 var atlas := Image.create_empty(seg * 3, h, false, Image.FORMAT_RGBA8) atlas.fill(Color(0, 0, 0, 0)) for i in 3: var t := _ui_tex(pieces[i]) if t == null: continue var im := t.get_image() im.convert(Image.FORMAT_RGBA8) im.resize(seg, h, Image.INTERPOLATE_BILINEAR) atlas.blit_rect(im, Rect2i(Vector2i.ZERO, Vector2i(seg, h)), Vector2i(i * seg, 0)) var np := NinePatchRect.new() np.texture = ImageTexture.create_from_image(atlas) np.patch_margin_left = seg np.patch_margin_right = seg return np # --- widgets ---------------------------------------------------------------- func _build_minimap(nx: int, ny: int) -> void: var cell := 64 var big := Image.create_empty(nx * cell, ny * cell, false, Image.FORMAT_RGBA8) big.fill(Color(0.08, 0.09, 0.11, 1.0)) for tx in nx: for ty in ny: var p: String = _world.call("chunk_dir", tx, ty) + "/minimap.dds" if not FileAccess.file_exists(p): continue var img: Image = _world.call("load_dds", p) if img == null: continue img.resize(cell, cell, Image.INTERPOLATE_BILINEAR) big.blit_rect(img, Rect2i(0, 0, cell, cell), Vector2i(tx * cell, ty * cell)) var mm_h := 192 * ny / nx if nx > 0 else 192 var frame := _nine("board", 32, 128) _minimap_frame = frame frame.set_anchors_preset(Control.PRESET_TOP_RIGHT) frame.position = Vector2(-192 - 24 - 12, 12) frame.size = Vector2(192 + 24, mm_h + 24) add_child(frame) _minimap_rect = TextureRect.new() _minimap_rect.texture = ImageTexture.create_from_image(big) _minimap_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE _minimap_rect.stretch_mode = TextureRect.STRETCH_SCALE _minimap_rect.position = Vector2(12, 12) _minimap_rect.size = Vector2(192, mm_h) frame.add_child(_minimap_rect) _dot = ColorRect.new() _dot.color = Color(1, 0.9, 0.2) _dot.size = Vector2(6, 6) _minimap_rect.add_child(_dot) func _build_status() -> void: # 40250 标准:血量、法力、体力、经验均由底部任务栏 Quickbar 的 Gauge_Board 与 EXP_Gauge_Board 承载, # HUD 不再在左下角渲染浮动的原型状态条。 pass # --- P0: live data setters ------------------------------------------------- func set_status_visible(v: bool) -> void: if _status_box and is_instance_valid(_status_box): _status_box.visible = v func set_vitals(hp: int, max_hp: int, sp: int, max_sp: int) -> void: _cached_hp = hp _cached_max_hp = max_hp _cached_sp = sp _cached_max_sp = max_sp if _hp_fill: _hp_fill.size.x = _hp_w * clampf(float(hp) / maxf(1.0, max_hp), 0.0, 1.0) if _mp_fill: _mp_fill.size.x = _hp_w * clampf(float(sp) / maxf(1.0, max_sp), 0.0, 1.0) if _hp_label: _hp_label.text = "%d / %d" % [hp, max_hp] if quickbar: if quickbar.has_method("set_hp"): quickbar.set_hp(hp, max_hp) if quickbar.has_method("set_sp"): quickbar.set_sp(sp, max_sp) func set_exp(xp: int, next_xp: int) -> void: _cached_exp = xp _cached_next_exp = next_xp if _exp_fill: _exp_fill.size.x = _hp_w * clampf(float(xp) / maxf(1.0, next_xp), 0.0, 1.0) if quickbar and quickbar.has_method("set_exp"): quickbar.set_exp(xp, next_xp) func set_level(lv: int) -> void: if _lvl_label: _lvl_label.text = "Lv %d" % lv func set_energy(value: int, max_value: int = 100) -> void: var cap := maxi(1, max_value) var v := clampi(value, 0, cap) if _energy_fill: _energy_fill.size.x = _hp_w * float(v) / float(cap) if _energy_label: _energy_label.text = "%d / %d" % [v, cap] func set_stamina(value: int, max_value: int) -> void: _cached_st = value _cached_max_st = max_value var cap := maxi(1, max_value) var v := clampi(value, 0, cap) if _stamina_fill: _stamina_fill.size.x = _hp_w * float(v) / float(cap) if _stamina_label: _stamina_label.text = "%d / %d" % [v, cap] if quickbar and quickbar.has_method("set_st"): quickbar.set_st(value, max_value) # P4:状态图标条。affects = [{type, point_idx, value, flag, duration}, ...] func set_affects(affects: Array) -> void: if _affect_row == null: _affect_row = HBoxContainer.new() _affect_row.set_anchors_preset(Control.PRESET_TOP_LEFT) _affect_row.position = Vector2(18, 18) _affect_row.add_theme_constant_override("separation", 3) add_child(_affect_row) for c in _affect_row.get_children(): c.queue_free() for a in affects: var sq := ColorRect.new() sq.custom_minimum_size = Vector2(22, 22) var t := int(a.get("type", 0)) sq.color = Color.from_hsv(fmod(t * 0.13, 1.0), 0.55, 0.9, 0.95) sq.tooltip_text = "affect #%d pt%d %+d %ds" % [t, int(a.get("point_idx", 0)), int(a.get("value", 0)), int(a.get("duration", 0))] _affect_row.add_child(sq) func set_target(nm: String, hp_pct: int) -> void: if _target_panel == null: _build_target_panel() _target_panel.visible = true _target_name.text = nm if nm != "" else "Target" _target_fill.size.x = 180.0 * clampf(hp_pct / 100.0, 0.0, 1.0) func clear_target() -> void: if _target_panel: _target_panel.visible = false func set_dungeon_destination(active: bool, world_pos: Vector3) -> void: _dungeon_destination = world_pos if _dungeon_panel: _dungeon_panel.visible = active if not active: _dungeon_label.text = "" func _build_dungeon_compass() -> void: _dungeon_panel = Control.new() _dungeon_panel.name = "DungeonCompass" _dungeon_panel.set_anchors_preset(Control.PRESET_CENTER_TOP) _dungeon_panel.position = Vector2(-110, 18) _dungeon_panel.size = Vector2(220, 42) _dungeon_panel.visible = false add_child(_dungeon_panel) var bg := ColorRect.new() bg.color = Color(0.04, 0.05, 0.08, 0.78) bg.size = _dungeon_panel.size bg.mouse_filter = Control.MOUSE_FILTER_IGNORE _dungeon_panel.add_child(bg) _dungeon_label = Label.new() _dungeon_label.position = Vector2(30, 5) _dungeon_label.add_theme_font_size_override("font_size", 12) _dungeon_panel.add_child(_dungeon_label) _dungeon_arrow = Label.new() _dungeon_arrow.text = "▲" _dungeon_arrow.position = Vector2(8, 9) _dungeon_arrow.add_theme_font_size_override("font_size", 18) _dungeon_panel.add_child(_dungeon_arrow) func _build_target_panel() -> void: _target_panel = Control.new() _target_panel.set_anchors_preset(Control.PRESET_CENTER_TOP) _target_panel.position = Vector2(-100, 16) _target_panel.size = Vector2(200, 40) add_child(_target_panel) _target_name = Label.new() _target_name.add_theme_font_size_override("font_size", 12) _target_name.position = Vector2(10, 0) _target_panel.add_child(_target_name) var slot := ColorRect.new() slot.color = Color(0, 0, 0, 0.55) slot.position = Vector2(10, 18) slot.size = Vector2(180, 12) _target_panel.add_child(slot) _target_fill = ColorRect.new() _target_fill.color = Color(0.85, 0.2, 0.2) _target_fill.size = Vector2(180, 12) slot.add_child(_target_fill) func _build_inventory() -> void: # 40250 标准:背包由 ui/inventory_ui.gd 统一管理,不再在此创建原型窗口。 pass func _input(_ev: InputEvent) -> void: pass func _process(_dt: float) -> void: if _player: if _dungeon_panel and _dungeon_panel.visible: var d := _dungeon_destination - _player.global_position var distance := Vector2(d.x, d.z).length() _dungeon_label.text = "副本目标 %.0fm" % distance # Player forward is +Z in this scene; rotate the north-facing arrow # relative to the current player yaw, keeping the target prompt local. _dungeon_arrow.rotation = atan2(d.x, d.z) - _player.rotation.y if _minimap_rect: var u := clampf(_player.position.x / _map_w_m, 0.0, 1.0) var v := clampf(_player.position.z / _map_h_m, 0.0, 1.0) _dot.position = Vector2(u * _minimap_rect.size.x - 3, v * _minimap_rect.size.y - 3)