- 桥梁与静态物体高度采样修复: - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
380 lines
14 KiB
GDScript
380 lines
14 KiB
GDScript
# 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)
|