402 lines
15 KiB
GDScript
402 lines
15 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
|
||
# 目标血条(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:
|
||
var box := VBoxContainer.new()
|
||
box.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
|
||
box.position = Vector2(18, -122)
|
||
box.add_theme_constant_override("separation", 6)
|
||
add_child(box)
|
||
for spec in [["gauge_red", 0.82], ["gauge_blue", 0.60]]:
|
||
var slot := _gauge("gauge_slot", 24)
|
||
slot.custom_minimum_size = Vector2(_hp_w, 24)
|
||
slot.size = Vector2(_hp_w, 24)
|
||
var fill := _gauge(spec[0], 24)
|
||
fill.position = Vector2(0, 0)
|
||
fill.size = Vector2(_hp_w * float(spec[1]), 24)
|
||
slot.add_child(fill)
|
||
box.add_child(slot)
|
||
if spec[0] == "gauge_red":
|
||
_hp_fill = fill
|
||
_hp_label = Label.new()
|
||
_hp_label.add_theme_font_size_override("font_size", 11)
|
||
_hp_label.position = Vector2(6, 5)
|
||
slot.add_child(_hp_label)
|
||
else:
|
||
_mp_fill = fill
|
||
# 体力条(40250 POINT_STAMINA,冲刺/骑乘时由 ServerCommand 驱动)。
|
||
var stamina_slot := ColorRect.new()
|
||
stamina_slot.color = Color(0, 0, 0, 0.55)
|
||
stamina_slot.custom_minimum_size = Vector2(_hp_w, 10)
|
||
stamina_slot.size = Vector2(_hp_w, 10)
|
||
_stamina_fill = ColorRect.new()
|
||
_stamina_fill.color = Color(0.25, 0.85, 0.35)
|
||
_stamina_fill.size = Vector2(0, 10)
|
||
stamina_slot.add_child(_stamina_fill)
|
||
_stamina_label = Label.new()
|
||
_stamina_label.add_theme_font_size_override("font_size", 9)
|
||
_stamina_label.position = Vector2(6, -1)
|
||
stamina_slot.add_child(_stamina_label)
|
||
box.add_child(stamina_slot)
|
||
# 能量条(原客户端 POINT_ENERGY,0..100)。资源缺失时仍用纯色条保持可读。
|
||
var energy_slot := ColorRect.new()
|
||
energy_slot.color = Color(0, 0, 0, 0.55)
|
||
energy_slot.custom_minimum_size = Vector2(_hp_w, 10)
|
||
energy_slot.size = Vector2(_hp_w, 10)
|
||
_energy_fill = ColorRect.new()
|
||
_energy_fill.color = Color(0.95, 0.65, 0.18)
|
||
_energy_fill.size = Vector2(0, 10)
|
||
energy_slot.add_child(_energy_fill)
|
||
_energy_label = Label.new()
|
||
_energy_label.add_theme_font_size_override("font_size", 9)
|
||
_energy_label.position = Vector2(6, -1)
|
||
energy_slot.add_child(_energy_label)
|
||
box.add_child(energy_slot)
|
||
# 经验条(细)+ 等级
|
||
var exp_slot := ColorRect.new()
|
||
exp_slot.color = Color(0, 0, 0, 0.5)
|
||
exp_slot.custom_minimum_size = Vector2(_hp_w, 6)
|
||
exp_slot.size = Vector2(_hp_w, 6)
|
||
_exp_fill = ColorRect.new()
|
||
_exp_fill.color = Color(0.9, 0.8, 0.2)
|
||
_exp_fill.size = Vector2(0, 6)
|
||
exp_slot.add_child(_exp_fill)
|
||
box.add_child(exp_slot)
|
||
_lvl_label = Label.new()
|
||
_lvl_label.add_theme_font_size_override("font_size", 12)
|
||
_lvl_label.text = "Lv 1"
|
||
box.add_child(_lvl_label)
|
||
|
||
# --- P0: live data setters -------------------------------------------------
|
||
|
||
func set_vitals(hp: int, max_hp: int, sp: int, max_sp: int) -> void:
|
||
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]
|
||
|
||
func set_exp(xp: int, next_xp: int) -> void:
|
||
if _exp_fill:
|
||
_exp_fill.size.x = _hp_w * clampf(float(xp) / maxf(1.0, next_xp), 0.0, 1.0)
|
||
|
||
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:
|
||
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]
|
||
|
||
# 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:
|
||
_inv = _nine("board", 32, 128)
|
||
_inv.set_anchors_preset(Control.PRESET_CENTER_RIGHT)
|
||
_inv.position = Vector2(-280, -200)
|
||
_inv.size = Vector2(256, 384)
|
||
_inv.visible = false
|
||
var t := Label.new()
|
||
t.text = " Inventory (I to close)"
|
||
t.position = Vector2(16, 12)
|
||
_inv.add_child(t)
|
||
add_child(_inv)
|
||
|
||
func _input(ev: InputEvent) -> void:
|
||
if ev is InputEventKey and ev.pressed and ev.keycode == KEY_I:
|
||
_inv.visible = not _inv.visible
|
||
|
||
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)
|