Complete remaining client gap features

This commit is contained in:
shenlei
2026-09-04 13:25:05 +09:00
parent 13ccb8d02d
commit 9a4a044da5
47 changed files with 6667 additions and 140 deletions
+342 -41
View File
@@ -21,7 +21,12 @@ const NameColor = preload("res://name_color.gd")
const TextTail = preload("res://text_tail.gd")
const ChatTail = preload("res://chat_tail.gd")
const NameShow = preload("res://name_show.gd")
const DamageEffect = preload("res://damage_effect.gd")
const EntityRules = preload("res://entity_rules.gd")
const PickShow = preload("res://pick_show.gd")
const NameTailLayout = preload("res://name_tail_layout.gd")
const TextMetrics = preload("res://text_metrics.gd")
const ScreenHpBar = preload("res://screen_hp_bar.gd")
signal entity_added(node: Node3D, vid: int)
signal entity_removed(vid: int)
@@ -43,9 +48,25 @@ const FUNC_MOB_SKILL := 4
const FUNC_EMOTION := 5
const FUNC_SKILL := 0x80
# EDamageFlag
const DAMAGE_DODGE := 1 << 2
const DAMAGE_CRITICAL := 1 << 5
# EDamageFlag / IsShowDamage / ProcessDamage 分类见 damage_effect.gdDamageEffect.DAMAGE_*)。
# §8.8 头顶名字 / 聊天气泡 billboard 的默认局部 y(世界单位)。seam ⑨(name_tail_layout.gd):
# 有「聊天」气泡时名字尾标顶到气泡上方 17px(换算按名字 Label3D 的 pixel_size)。
const NAME_TAG_DEFAULT_Y := 2.1
const BUBBLE_TAG_Y := 2.35
# §8.8 公会名 / 称号 / 等级子标签的默认局部 y(竖排近似:公会名在名字上、称号 / 等级在下)。
# seam ⑦(name_tail_layout.gd):参考端 ArrangeTextTail 把它们锚在名字尾标 pTextTail->y 上,
# 名字因聊天气泡上移时三者整体跟随——_reposition_name_tag 按 name_tail_lift_delta 补这段位移。
const SUBTAG_DEFAULT_Y := {"GuildTag": 2.34, "TitleTag": 1.86, "LevelTag": 1.74}
# seam ⑦ 水平排版(增量 121):ArrangeTextTail 按 GetTextSize 精确像素排称号 / 等级,
# poc 无字体度量 -> 按「字符数 * 约 7px」估文本宽(与 ground_items 的 TAG_CHAR_W_PX 同源)。
const SUBTAG_CHAR_W_PX := 7.0
# §8.8:参考端的实体 HP 条属于屏幕尾标,而不是 3D billboard。条的锚点
# 保持在原占位条高度附近;实际像素位置由 Camera3D.unproject_position() 计算。
const HP_BAR_WORLD_Y := 1.95
const HP_BAR_SCREEN_MARGIN := 32.0
# CLIENT-GAP §2.1 two-layer model. The data layer (M2Client / EntityStore) always
# holds every known entity; a scene node under `parent` exists only while the
@@ -88,11 +109,22 @@ var _fading := {} # vid:int -> true while a culled node twee
# §8.4 / §8.6 头顶名字「是否显示」(name_show.gd)。game.py::__IsShowName() 的两个来源:
# _always_show_name —— uigameoption「总显示名字」radioCPythonSystem::IsAlwaysShowName
# _alt_held —— LALT 按住(game.py ShowName/HideName 的 ShowNameFlag
# 另加 forced 路径:_target_vidtargetBoard.GetTargetVID()+ chat 尾标 bNameFlag
# 另加 forced 路径:_target_vidtargetBoard.GetTargetVID()+ chat 尾标 bNameFlag +
# _hover_vidgame.py OnRender chr.Pick() -> ShowCharacterTextTail,见 pick_show.gd)。
var _always_show_name := NameShow.ALWAYS_SHOW_NAME_DEFAULT
var _alt_held := false
var _target_vid := 0
var _hover_vid := 0 # player_controller._update_hover_cursor 射线拾到的角色 vid0 = 无)
# §8.8 / §5.4 伤害飘字(damage_effect.gd)。CPythonSystem::IsShowDamage 门(uigameoption
# 「伤害数字」radio+ CInstanceBase::m_DamageQueue(每帧 pop 一条)+ ProcessDamage 分类。
var _show_damage := DamageEffect.SHOW_DAMAGE_DEFAULT
var _damage_queue: Array = [] # m_DamageQueue{vid, amount, flag, self, target}
var _dmg_self_toggle := false # m_bDamageEffectTypeSELFDAMAGE / SELFDAMAGE2 交替)
var _fly_mgr: Node # FlyManagerfly_object.gd)—— GC_CREATE_FLY 的真实弹道 + 命中判定。§3.6
var _hp_layer: CanvasLayer # 屏幕空间尾标层(低于 HUD / UiManager
var _hp_overlay: Control
var _hp_bars := {} # vid:int -> ScreenHpBar
var _camera: Camera3D
# Fishing surface provider bridge. NetWorld owns entity presentation while
# Metin2World owns map data; keep the ClientVS22 GetFishingRot query on the
@@ -140,12 +172,28 @@ func setup(m2client: Node, mount: Node3D) -> void:
client.pvp_changed.connect(_on_pvp_changed)
if client.has_signal("duel_changed"):
client.duel_changed.connect(_on_duel_changed)
# Guild marks are downloaded by AppFlow on the side connection. The mark
# store is independent from the entity stream, so a character can already
# be visible when its 16x12 image arrives; refresh the affected text tails
# on both the initial completion and an incremental GC_MARK_UPDATE.
if client.has_signal("guild_marks_ready"):
client.guild_marks_ready.connect(func(_count: int): _retint_all_text_tails())
if client.has_signal("guild_mark_updated"):
client.guild_mark_updated.connect(func(guild_id: int, _img_idx: int): _refresh_guild_mark(guild_id))
# §3.6 飞行物实体:GC_CREATE_FLY 之后由 FlyManager 跑真实弹道 + 线段命中判定。
_fly_mgr = FlyObject.new()
_fly_mgr.name = "FlyManager"
add_child(_fly_mgr)
_fly_mgr.setup(parent, world)
_fly_mgr.exploded.connect(func(wp: Vector3, cause: String): fly_exploded.emit(wp, cause))
_ensure_hp_overlay()
# GameScene normally calls this after creating the orbit camera. Keeping the
# setter explicit makes camera hand-off / map reload deterministic and leaves
# headless tests independent of the active viewport camera.
func set_camera(camera: Camera3D) -> void:
_camera = camera
_update_hp_bars()
# 已在局内(重连 / setup 是协程,进来时 spawn burst 已被 pump 抽干)——
# 把当前所有实体补建一遍,别漏掉进游戏那一批怪 / NPC / 玩家。
@@ -173,6 +221,7 @@ func set_local_vid(vid: int) -> void:
if n:
_by_vid.erase(vid)
n.queue_free()
_remove_hp_bar(vid)
func set_local_node(node: Node3D) -> void:
_local_node = node
@@ -192,6 +241,10 @@ func set_always_show_name(on: bool) -> void:
_always_show_name = on
_refresh_all_name_visibility()
# uigameoption「伤害数字」radio -> CPythonSystem::SetShowDamageFlagAddDamageEffect 的入队门)。
func set_show_damage(on: bool) -> void:
_show_damage = on
# targetBoard.GetTargetVID():目标始终强显名字(game.py OnRender ShowCharacterTextTail(targetVID))。
func set_target_vid(vid: int) -> void:
if _target_vid == vid:
@@ -202,6 +255,18 @@ func set_target_vid(vid: int) -> void:
if v != 0 and _by_vid.has(v):
_set_name_visibility(_by_vid[v])
# game.py OnRenderwndMgr.IsPickedWindow 门内 chr.Pick() 命中的角色 -> ShowCharacterTextTail
# (强显名字,绕过 __IsShowName())。poc 由 player_controller 的射线悬停拾取喂入。
# 见 pick_show.gd(第 27 个 canonical)与增量 113 的 _target_vid 同路数。
func set_hover_vid(vid: int) -> void:
if _hover_vid == vid:
return
var prev := _hover_vid
_hover_vid = vid
for v in [prev, vid]:
if v != 0 and _by_vid.has(v):
_set_name_visibility(_by_vid[v])
func _refresh_all_name_visibility() -> void:
for node in _by_vid.values():
_set_name_visibility(node)
@@ -215,7 +280,7 @@ func _main_planar_cm() -> Vector2:
return Vector2(mc.x, mc.y)
return Vector2(INF, INF)
# name_show.name_visible_for 的 net_world 侧组装:forcedtarget / chat bNameFlag+
# name_show.name_visible_for 的 net_world 侧组装:forcedtarget / hover / chat bNameFlag+
# __IsShowName()_always_show_name or _alt_held+ 3500 平面距 + isShow/wall/CanPick 资格。
func _name_shown(root: Node3D) -> bool:
if root == null or not is_instance_valid(root):
@@ -228,7 +293,9 @@ func _name_shown(root: Node3D) -> bool:
var is_wall := _is_wall(int(e.get("race", 0)))
# CanPickInstance() 更细(隐身 / 特定 affect 亦不可拾取)—— 这里只按死透近似,记 seam ⑩。
var alive := not dead
var forced := vid != 0 and vid == _target_vid
# game.py OnRendertargetBoard.GetTargetVID()(增量 113+ chr.Pick() 悬停命中
# _hover_vid,增量 116 / pick_show.gd)都走 ShowCharacterTextTail 强显。
var forced := vid != 0 and (vid == _target_vid or vid == _hover_vid)
var ct: Variant = _chat_tails.get(vid, null)
if ct is Dictionary and bool(ct.get("name_flag", false)):
forced = true
@@ -253,6 +320,10 @@ func _set_name_visibility(root: Node3D) -> void:
var st := root.get_node_or_null(sub) as Label3D
if st:
st.visible = shown and st.text != ""
var mark := root.get_node_or_null("GuildMark") as Sprite3D
if mark:
var guild_tag := root.get_node_or_null("GuildTag") as Label3D
mark.visible = shown and guild_tag != null and guild_tag.text != ""
# ClientVS22 destroys the network actor manager when Loading starts. This is
# called from M2Client.world_reset before the replacement connection sends its
@@ -264,7 +335,9 @@ func clear_for_map_change() -> void:
node.queue_free()
entity_removed.emit(int(vid))
_by_vid.clear()
_clear_hp_bars()
_fading.clear()
_damage_queue.clear()
_main_vid = 0
_pvp_relations.clear()
_duel_opponents.clear()
@@ -325,6 +398,7 @@ func _on_spawn(d: Dictionary) -> void:
var ch_type := int(d.get("ch_type", 0))
node.set_meta("cursor_shape", "TALK" if ch_type in [1, 4] else "ATTACK")
_by_vid[vid] = node
_sync_hp_bar(vid, d)
_set_name_visibility(node)
_refresh_shop_sign(node, str(d.get("shop_sign", "")))
_refresh_pvp_tag(vid)
@@ -340,6 +414,7 @@ func _on_info(vid: int, d: Dictionary) -> void:
var n: Node3D = _by_vid.get(vid, null)
if n == null:
return
_sync_hp_bar(vid, d)
var nm := _display_name(d)
if nm != "":
if n.has_node("Label3D"):
@@ -628,6 +703,90 @@ func _apply_text_tail_to(n: Node3D, e: Dictionary) -> void:
ltag.modulate = lv["color"]
ltag.visible = shown and ltag.text != ""
_apply_guild_mark(n, guild_id, gtag, shown)
_layout_subtags_x(n, ttag, ltag)
# Keep the mark on the same adjusted screen row as the name immediately on
# spawn/info refresh, not only after the next chat-tail update.
_reposition_name_tag(n)
# ArrangeTextTail 角色块(PythonTextTail.cpp:211-320 非 EUROPE 分支)的逐像素水平排布:
# 称号在名字锚点左侧 nameW/2 + 8、等级再往左一个称号宽(无称号时 nameW/2 + 4)、
# 名字本身右移 fxAdd(8 / 4 / 0)。公会名的水平锚点参考端只在「有公会徽记」时才改写,
# poc 无徽记 -> GuildTag 留 x = 0(名字中线正上方)。文本宽度取各 Label3D 的真实字体度量
# TextMetrics.label_width = GetTextSize 1:1,增量 123SUBTAG_CHAR_W_PX 仅作无字体兜底)。
# 经 name_tail_layout.gd 纯静态镜像。
func _layout_subtags_x(n: Node3D, ttag: Label3D, ltag: Label3D) -> void:
var nlbl := n.get_node_or_null("Label3D") as Label3D
var gtag := n.get_node_or_null("GuildTag") as Label3D
var mark := n.get_node_or_null("GuildMark") as Sprite3D
var has_title := ttag != null and ttag.text != ""
var has_level := ltag != null and ltag.text != ""
var name_w := 0.0
if nlbl:
name_w = maxf(1.0, TextMetrics.label_width(nlbl, SUBTAG_CHAR_W_PX))
var title_w := 0.0
if has_title:
title_w = maxf(1.0, TextMetrics.label_width(ttag, SUBTAG_CHAR_W_PX))
if nlbl:
nlbl.position.x = NameTailLayout.subtag_world_x(
NameTailLayout.name_x_shift(has_title, has_level), nlbl.pixel_size)
if ttag:
ttag.position.x = NameTailLayout.subtag_world_x(
NameTailLayout.title_x_offset(name_w), ttag.pixel_size) if has_title else 0.0
if ltag:
ltag.position.x = NameTailLayout.subtag_world_x(
NameTailLayout.level_x_offset(name_w, title_w, has_title), ltag.pixel_size) if has_level else 0.0
if gtag:
if mark != null and mark.texture != null and gtag.text != "":
var guild_w := maxf(1.0, TextMetrics.label_width(gtag, SUBTAG_CHAR_W_PX))
var mark_w := maxf(1.0, float(mark.texture.get_width()))
gtag.position.x = NameTailLayout.subtag_world_x(
NameTailLayout.guild_name_x_offset(mark_w), gtag.pixel_size)
mark.position.x = NameTailLayout.subtag_world_x(
NameTailLayout.mark_x_offset(guild_w, mark_w), mark.pixel_size)
else:
gtag.position.x = 0.0 # 无已下载会徽时保持参考端 if(pMark && pGuild) 不成立
if mark:
mark.position.x = 0.0
func _apply_guild_mark(n: Node3D, guild_id: int, guild_tag: Label3D, shown: bool) -> void:
# CPythonTextTail only creates pMarkInstance when a guild id resolves to a
# downloaded mark. Do not leave a stale image after a guild change or a
# mark-table refresh that no longer contains this guild.
var mark := n.get_node_or_null("GuildMark") as Sprite3D
if guild_id == 0 or guild_tag == null or guild_tag.text == "" \
or client == null or not client.has_method("get_guild_mark_image"):
if mark:
mark.queue_free()
return
var img: Image = client.get_guild_mark_image(guild_id)
if img == null or img.get_width() <= 0 or img.get_height() <= 0:
if mark:
mark.queue_free()
return
if mark == null:
mark = Sprite3D.new()
mark.name = "GuildMark"
mark.billboard = BaseMaterial3D.BILLBOARD_ENABLED
mark.no_depth_test = true
mark.pixel_size = 0.005
mark.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
n.add_child(mark)
mark.texture = ImageTexture.create_from_image(img)
mark.visible = shown
mark.set_meta("guild_id", guild_id)
func _refresh_guild_mark(guild_id: int) -> void:
if guild_id == 0 or client == null or not client.has_method("get_entities"):
return
for vid in _by_vid.keys():
var n: Node3D = _by_vid[vid]
if not is_instance_valid(n):
continue
var e: Dictionary = client.get_entity(int(vid))
if int(e.get("guild", 0)) == guild_id:
_apply_text_tail_to(n, e)
func _apply_text_tail(vid: int) -> void:
if vid == 0 or client == null or not client.has_method("get_entity"):
return
@@ -660,6 +819,7 @@ func _on_despawn(vid: int) -> void:
_duel_opponents.erase(vid)
_fading.erase(vid)
_chat_tails.erase(vid)
_remove_hp_bar(vid)
var n: Node3D = _by_vid.get(vid, null)
if n:
_by_vid.erase(vid)
@@ -822,7 +982,7 @@ func _apply_chat_tail(n: Node3D, tail: Dictionary) -> void:
if lbl == null:
lbl = Label3D.new()
lbl.name = "bubble"
lbl.position.y = 2.35
lbl.position.y = BUBBLE_TAG_Y
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
lbl.no_depth_test = true
lbl.pixel_size = 0.0055
@@ -832,6 +992,8 @@ func _apply_chat_tail(n: Node3D, tail: Dictionary) -> void:
lbl.modulate = tail.get("color", ChatTail.CHAT_COLOR)
# UpdateAllTextTailchat 尾标 bNameFlag TRUE 时强制显示该角色名字(不受 3500 限制)。
_set_name_visibility(n)
# UpdateShowingTextTail :110-114:有「聊天」气泡(bNameFlag)时名字尾标顶到气泡上方 17px。
_reposition_name_tag(n)
func _expire_chat_tails() -> void:
# UpdateAllTextTailLivingTime < now 即删除对应 "bubble" 节点
@@ -843,6 +1005,40 @@ func _expire_chat_tails() -> void:
lbl.queue_free()
# 尾标没了 -> bNameFlag 强显撤销,名字回到 __IsShowName / 距离裁决。
_set_name_visibility(n)
# 气泡没了 -> 名字尾标从「贴气泡上方」回默认局部 y(chat 尾标不在
# m_ChatTailMap 里,UpdateShowingTextTail :110 的 find 落空)。
_reposition_name_tag(n)
# CPythonTextTail::UpdateShowingTextTail :101-118 —— 角色名字尾标投影后,若该 VID 有一条
# 「聊天」气泡(m_ChatTailMap 命中且 bNameFlag TRUE,即 RegisterChatTail 而非
# RegisterInfoTail),把名字尾标顶到气泡上方 17px(屏幕 y - 17 → poc 世界 y + 17*pixel_size)。
# 经 name_tail_layout.gd 纯静态镜像。
func _reposition_name_tag(n: Node3D) -> void:
if n == null or not is_instance_valid(n):
return
var tag := n.get_node_or_null("Label3D") as Label3D
if tag == null:
return
var vid := int(n.get_meta("vid", 0))
var ct: Variant = _chat_tails.get(vid, null)
var has_tail := ct is Dictionary
var name_flag := has_tail and bool((ct as Dictionary).get("name_flag", false))
var follows := NameTailLayout.name_follows_bubble(has_tail, name_flag)
tag.position.y = NameTailLayout.name_tail_world_y(
NAME_TAG_DEFAULT_Y, BUBBLE_TAG_Y, follows, tag.pixel_size)
# ArrangeTextTail :228/:258/:283 —— 公会名 / 称号 / 等级子标签都锚在名字尾标 pTextTail->y
# 上,名字因聊天气泡上移时三者整体跟随同一段位移(seam ⑦ 仅剩竖排像素排布近似)。
var lift := NameTailLayout.name_tail_lift_delta(
NAME_TAG_DEFAULT_Y, BUBBLE_TAG_Y, follows, tag.pixel_size)
for sub_name in SUBTAG_DEFAULT_Y:
var st := n.get_node_or_null(sub_name) as Label3D
if st:
st.position.y = NameTailLayout.subtag_world_y(SUBTAG_DEFAULT_Y[sub_name], lift)
var mark := n.get_node_or_null("GuildMark") as Sprite3D
if mark:
# ArrangeTextTail :227 puts the mark 26 px above the name instance; it
# follows the same already-adjusted name y when a chat tail is active.
mark.position.y = NAME_TAG_DEFAULT_Y + NameTailLayout.MARK_ABOVE_NAME_PX * tag.pixel_size + lift
func _on_vitals(vid: int) -> void:
var n: Node3D = _by_vid.get(vid, null)
@@ -853,10 +1049,7 @@ func _on_vitals(vid: int) -> void:
var max_hp := int(e.get("max_hp", 0))
var dead := bool(e.get("dead", false))
vitals.emit(vid, hp, max_hp, dead)
if n and n.has_node("HpBar") and max_hp > 0:
var bar := n.get_node("HpBar") as Sprite3D
bar.scale.x = clampf(float(hp) / float(max_hp), 0.0, 1.0)
bar.modulate = Color(0.9, 0.2, 0.2) if not dead else Color(0.3, 0.3, 0.3)
_sync_hp_bar_values(vid, hp, max_hp, dead)
func _on_dead(vid: int) -> void:
var n: Node3D = _by_vid.get(vid, null)
@@ -984,22 +1177,54 @@ func _on_fishing_event(subheader: int, info: int, dir: int) -> void:
n.call("set_anim_state", state)
fishing_event.emit(info, subheader, dir)
# GC_DAMAGE_INFO -> RecvDamageInfoPacket -> CInstanceBase::AddDamageEffect。
# damage_number 信号是本移植扩展(战斗日志 / 测试消费),始终发;
# 飘字本体照 AddDamageEffect:仅 IsShowDamage() 时入 m_DamageQueueProcessDamage 每帧 pop 一条。
func _on_damage(vid: int, amount: int, flag: int) -> void:
var crit := (flag & DAMAGE_CRITICAL) != 0
var dodge := (flag & DAMAGE_DODGE) != 0
var crit := (flag & DamageEffect.DAMAGE_CRITICAL) != 0
var dodge := (flag & DamageEffect.DAMAGE_DODGE) != 0
damage_number.emit(vid, amount, crit, dodge)
var n: Node3D = _by_vid.get(vid, null)
if n == null or parent == null:
if not DamageEffect.valid_amount(amount):
return # RecvDamageInfoPacketdamage < 0 -> TraceError,不处理
if not DamageEffect.should_queue(_show_damage):
return # AddDamageEffect!IsShowDamage() -> 不入队
_damage_queue.append({
"vid": vid, "amount": amount, "flag": flag,
"self": DamageEffect.is_self(vid, _main_vid),
"target": DamageEffect.is_target(vid, _target_vid),
})
# ProcessDamage:每帧 pop_front 一条。DODGE|BLOCK -> "MISS"nontarget 数字 -> 不渲染;
# 其余把 amount 铺一块 billboard Label3Dseam ⑪:无 damagevalue dds 图集 / 逐位特效)。
func _process_damage_queue() -> void:
if _damage_queue.is_empty():
return
var d: Dictionary = _damage_queue.pop_front()
var n: Node3D = _by_vid.get(int(d["vid"]), null)
if n == null or not is_instance_valid(n) or parent == null:
return
var info := DamageEffect.classify(int(d["flag"]), bool(d["self"]), bool(d["target"]))
if not info["show"]:
return # ProcessDamagenontarget_ 后直接 return(不渲染)
var text := "MISS"
if info["kind"] == DamageEffect.Kind.DIGITS:
if DamageEffect.digits(int(d["amount"])).is_empty():
return # while(damage>0) 零次 -> 无贴图
text = str(int(d["amount"]))
# self 数字:SELFDAMAGE / SELFDAMAGE2 交替(seam ⑪:退化成落字水平抖动符号交替)。
var jitter := 0.0
if bool(d["self"]) and info["kind"] == DamageEffect.Kind.DIGITS:
jitter = 0.3 if DamageEffect.self_damage_slot(_dmg_self_toggle) == 0 else -0.3
_dmg_self_toggle = not _dmg_self_toggle
var lbl := Label3D.new()
lbl.text = "MISS" if dodge else str(amount)
lbl.modulate = Color(1.0, 0.85, 0.2) if crit else Color(1, 1, 1)
lbl.font_size = 48 if crit else 32
lbl.text = text
lbl.modulate = Color(1, 1, 1) # 该 build 无独立暴击 / self 颜色(ProcessDamage
lbl.font_size = 32
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
lbl.no_depth_test = true
lbl.pixel_size = 0.007
parent.add_child(lbl)
lbl.global_position = n.global_position + Vector3(randf_range(-0.3, 0.3), 1.9, 0)
lbl.global_position = n.global_position + Vector3(jitter, 1.9, 0)
var tw := create_tween()
tw.set_parallel(true)
tw.tween_property(lbl, "global_position:y", lbl.global_position.y + 1.2, 0.8)
@@ -1010,6 +1235,7 @@ func _process(dt: float) -> void:
if client == null or parent == null:
return
_update_visibility()
_process_damage_queue() # §8.8m_DamageQueue 每帧 pop 一条(CInstanceBase::ProcessDamage
# §8.8:名字色随队伍 / 帝国名色模式 / PVP·决斗边沿变化——用 ~2Hz 节流整表刷新
# 兜底(单发路径已覆盖 spawn/info/pvp/duel/main_set;参考端每帧都算 GetNameColor)。
_name_color_accum += dt
@@ -1047,6 +1273,7 @@ func _process(dt: float) -> void:
n.set_meta("moving", moving)
n.set_meta("walk_mode", walk_mode)
_apply_anim(n, f, moving, walk_mode)
_update_hp_bars()
func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1) -> void:
var state := "wait"
@@ -1086,13 +1313,14 @@ func _make_placeholder(d: Dictionary) -> Node3D:
_attach_nameplate(root, d)
return root
# 头顶名字 Label3D + HP 条 Sprite3D(占位胶囊和真模型共用)。
# 头顶名字 Label3D + 屏幕空间 HP 条(占位胶囊和真模型共用)。HP 条由
# _ensure_hp_overlay() 创建到 CanvasLayer_update_hp_bars() 负责世界坐标投影。
func _attach_nameplate(root: Node3D, d: Dictionary) -> void:
var tag := Label3D.new()
tag.name = "Label3D"
var nm := _display_name(d)
tag.text = nm if nm != "" else "vid %d" % int(d.get("vid", 0))
tag.position.y = 2.1
tag.position.y = NAME_TAG_DEFAULT_Y
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
tag.no_depth_test = true
tag.pixel_size = 0.006
@@ -1103,19 +1331,9 @@ func _attach_nameplate(root: Node3D, d: Dictionary) -> void:
# c_fyGuildNamePosition(15px)、称号 / 等级贴右侧。这里在 billboard 3D 里近似成竖排
# (公会名上、称号 / 等级下),精确像素排版差异记为 §8.8 seam ⑦。内容 / 颜色由
# _apply_text_tail 按 RefreshTextTail / AttachTitle / AttachLevel 决定,初始空 = 隐藏。
root.add_child(_mk_sub_tag("GuildTag", 2.34, TextTail.GUILD_NAME_COLOR))
root.add_child(_mk_sub_tag("TitleTag", 1.86, Color(1, 1, 1)))
root.add_child(_mk_sub_tag("LevelTag", 1.74, TextTail.LEVEL_COLOR))
var bar := Sprite3D.new()
bar.name = "HpBar"
bar.texture = _white_1px()
bar.pixel_size = 0.01 # 100px -> 1m
bar.position.y = 1.95
bar.billboard = BaseMaterial3D.BILLBOARD_ENABLED
bar.no_depth_test = true
bar.modulate = Color(0.9, 0.2, 0.2)
bar.scale.x = 1.0
root.add_child(bar)
root.add_child(_mk_sub_tag("GuildTag", SUBTAG_DEFAULT_Y["GuildTag"], TextTail.GUILD_NAME_COLOR))
root.add_child(_mk_sub_tag("TitleTag", SUBTAG_DEFAULT_Y["TitleTag"], Color(1, 1, 1)))
root.add_child(_mk_sub_tag("LevelTag", SUBTAG_DEFAULT_Y["LevelTag"], TextTail.LEVEL_COLOR))
func _mk_sub_tag(sub_name: String, y: float, col: Color) -> Label3D:
var t := Label3D.new()
@@ -1129,10 +1347,93 @@ func _mk_sub_tag(sub_name: String, y: float, col: Color) -> Label3D:
t.visible = false
return t
static var _white_tex: Texture2D
static func _white_1px() -> Texture2D:
if _white_tex == null:
var img := Image.create(100, 6, false, Image.FORMAT_RGBA8)
img.fill(Color.WHITE)
_white_tex = ImageTexture.create_from_image(img)
return _white_tex
func _ensure_hp_overlay() -> void:
if _hp_layer != null and is_instance_valid(_hp_layer):
return
_hp_layer = CanvasLayer.new()
_hp_layer.name = "ScreenHpLayer"
_hp_layer.layer = 5
add_child(_hp_layer)
_hp_overlay = Control.new()
_hp_overlay.name = "HpOverlay"
_hp_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
_hp_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
_hp_layer.add_child(_hp_overlay)
func _make_hp_bar(vid: int, e: Dictionary) -> Control:
_ensure_hp_overlay()
var bar := _hp_bars.get(vid, null) as Control
if bar == null or not is_instance_valid(bar):
bar = ScreenHpBar.new()
bar.name = "hp_%d" % vid
_hp_overlay.add_child(bar)
_hp_bars[vid] = bar
_sync_hp_bar_values(vid, int(e.get("hp", 0)), int(e.get("max_hp", 0)),
bool(e.get("dead", false)))
return bar
func _sync_hp_bar(vid: int, e: Dictionary) -> void:
if vid == 0 or e.is_empty():
return
_make_hp_bar(vid, e)
_sync_hp_bar_values(vid, int(e.get("hp", 0)), int(e.get("max_hp", 0)),
bool(e.get("dead", false)))
func _sync_hp_bar_values(vid: int, hp: int, max_hp: int, dead: bool) -> void:
var bar := _hp_bars.get(vid, null) as Control
if bar == null or not is_instance_valid(bar):
return
if bar.has_method("set_vitals"):
bar.call("set_vitals", hp, max_hp, dead)
bar.visible = max_hp > 0 and not dead
func _remove_hp_bar(vid: int) -> void:
var bar := _hp_bars.get(vid, null) as Control
_hp_bars.erase(vid)
if bar != null and is_instance_valid(bar):
bar.queue_free()
func _clear_hp_bars() -> void:
for vid in _hp_bars.keys():
var bar := _hp_bars[vid] as Control
if bar != null and is_instance_valid(bar):
bar.queue_free()
_hp_bars.clear()
func _update_hp_bars() -> void:
if _hp_bars.is_empty():
return
var cam := _camera
if cam == null or not is_instance_valid(cam):
cam = get_viewport().get_camera_3d()
var viewport_size := get_viewport().get_visible_rect().size
if cam == null or viewport_size.x <= 0.0 or viewport_size.y <= 0.0:
for bar in _hp_bars.values():
if is_instance_valid(bar):
bar.visible = false
return
var viewport_rect := Rect2(Vector2.ZERO, viewport_size).grow(HP_BAR_SCREEN_MARGIN)
for vid in _hp_bars.keys():
var bar := _hp_bars[vid] as Control
var node := _by_vid.get(vid, null) as Node3D
if bar == null or not is_instance_valid(bar) or node == null \
or not is_instance_valid(node):
if bar != null and is_instance_valid(bar):
bar.visible = false
continue
var e: Dictionary = client.get_entity(int(vid))
var max_hp := int(e.get("max_hp", 0))
var dead := bool(e.get("dead", false))
if max_hp <= 0 or dead:
bar.visible = false
continue
var anchor := node.global_position + Vector3(0.0, HP_BAR_WORLD_Y, 0.0)
if cam.is_position_behind(anchor):
bar.visible = false
continue
var screen_pos := cam.unproject_position(anchor)
if not viewport_rect.has_point(screen_pos):
bar.visible = false
continue
bar.position = screen_pos - Vector2(bar.size.x * 0.5, 0.0)
bar.visible = true