Files
mtgodot-poc/project/hud.gd
T
shenandshen 66d217b313 feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

380 lines
14 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.
# 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 "<pack>/ymir work/ui/pattern/<name>.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)