完善客户端功能并同步差距文档
This commit is contained in:
+405
-59
@@ -16,11 +16,19 @@
|
||||
# nw.main_entity_ready.connect(func(node, vid): _cam.target = node)
|
||||
extends Node
|
||||
|
||||
const FlyObject = preload("res://fly_object.gd")
|
||||
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 EntityRules = preload("res://entity_rules.gd")
|
||||
|
||||
signal entity_added(node: Node3D, vid: int)
|
||||
signal entity_removed(vid: int)
|
||||
signal main_entity_ready(node: Node3D, vid: int)
|
||||
signal chat_line(type: int, vid: int, text: String)
|
||||
signal fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool)
|
||||
signal fly_exploded(world_pos: Vector3, cause: String) # §3.6 飞行物命中 / 超程 / 触地
|
||||
signal fishing_event(vid: int, subheader: int, dir: int)
|
||||
signal motion_event(vid: int, victim_vid: int, motion: int)
|
||||
signal vitals(vid: int, hp: int, max_hp: int, dead: bool)
|
||||
@@ -58,8 +66,10 @@ const WALL_RACES := [14201, 14202, 14203, 14204]
|
||||
var client: Node # M2Client
|
||||
var parent: Node3D # 实体挂这下面
|
||||
var world: Node # Metin2World(可空,用 sample_height 贴地)
|
||||
var follow_lerp := 14.0 # 位置逼近速度
|
||||
var snap_dist := 6.0 # 超过这么多米直接瞬移(初次出现/大跳)
|
||||
# §3.2 后位置插值全在 C++ EntityStore::tick() 里做,这里直接跟随 e.pos。
|
||||
# 两个字段保留只为兼容外部读取 / 旧测试,_process 不再使用。
|
||||
var follow_lerp := 14.0
|
||||
var snap_dist := 6.0
|
||||
|
||||
var _by_vid := {} # vid:int -> Node3D
|
||||
var _main_vid := 0
|
||||
@@ -67,10 +77,35 @@ var _local_vid := 0 # 由 net_play 设:这个 vid 由本地
|
||||
var _local_node: Node3D # 本地玩家的真模型(不挂在 _mount 下)
|
||||
var _model_factory: Callable # func(Dictionary) -> Node3D
|
||||
var name_resolver: Callable = Callable() # func(entity_dict) -> String(怪名走 mob_proto)
|
||||
var kind_resolver: Callable = Callable() # §8.8 func(entity_dict) -> int(0 PC/1 NPC/2 怪/3 石/4 warp;net_play._entity_kind)
|
||||
var title_name_resolver: Callable = Callable() # §8.8 func(grade:int) -> String(PVP_LEVEL<grade> 本地化;未注入回退裸 key,seam ⑧)
|
||||
var _name_color_accum := 0.0 # §8.8 头顶名字色 / 称号标签的节流刷新累加器(~2Hz)
|
||||
var _chat_tails := {} # §8.8 vid:int -> {text,color,name_flag,living_time}(CPythonTextTail::m_ChatTailMap)
|
||||
var _pvp_relations := {} # unordered pair key -> {src_vid,dst_vid,mode}
|
||||
var _duel_opponents := {} # server-provided duel opponent VID set
|
||||
var _duel_cannot_attack := false
|
||||
var _fading := {} # vid:int -> true while a culled node tweens out (not re-created until done)
|
||||
# §8.4 / §8.6 头顶名字「是否显示」(name_show.gd)。game.py::__IsShowName() 的两个来源:
|
||||
# _always_show_name —— uigameoption「总显示名字」radio(CPythonSystem::IsAlwaysShowName)
|
||||
# _alt_held —— LALT 按住(game.py ShowName/HideName 的 ShowNameFlag)
|
||||
# 另加 forced 路径:_target_vid(targetBoard.GetTargetVID())+ chat 尾标 bNameFlag。
|
||||
var _always_show_name := NameShow.ALWAYS_SHOW_NAME_DEFAULT
|
||||
var _alt_held := false
|
||||
var _target_vid := 0
|
||||
var _fly_mgr: Node # FlyManager(fly_object.gd)—— GC_CREATE_FLY 的真实弹道 + 命中判定。§3.6
|
||||
|
||||
# Fishing surface provider bridge. NetWorld owns entity presentation while
|
||||
# Metin2World owns map data; keep the ClientVS22 GetFishingRot query on the
|
||||
# latter and expose it without making NetPlay depend on the native class.
|
||||
func get_fishing_rotation(gx_m: float, gz_m: float, heading_deg: float) -> float:
|
||||
if world == null or not world.has_method("get_fishing_rotation"):
|
||||
return -2.0 # provider unavailable; NetPlay keeps the protocol-only fallback
|
||||
return float(world.call("get_fishing_rotation", gx_m, gz_m, heading_deg))
|
||||
|
||||
func can_fishing_position(gx_m: float, gz_m: float, heading_deg: float) -> bool:
|
||||
if world == null or not world.has_method("can_fishing_position"):
|
||||
return true
|
||||
return bool(world.call("can_fishing_position", gx_m, gz_m, heading_deg))
|
||||
|
||||
func setup(m2client: Node, mount: Node3D) -> void:
|
||||
client = m2client
|
||||
@@ -82,8 +117,10 @@ func setup(m2client: Node, mount: Node3D) -> void:
|
||||
client.entity_info.connect(_on_info)
|
||||
client.chat.connect(func(t, v, s):
|
||||
chat_line.emit(t, v, s)
|
||||
if t in [0, 3, 4, 6]: # 普通/队伍/公会/喊话 -> 头顶气泡
|
||||
_bubble(int(v), String(s)))
|
||||
# §8.8 RecvChatPacket:1372 —— 带 VID 的聊天里,除 SHOUT / COMMAND 外都进头顶「聊天尾标」
|
||||
# (line 去掉 "名字: " 前缀,INFO/NOTICE 类不去)
|
||||
if int(v) != 0 and ChatTail.wants_chat_tail(int(t)):
|
||||
_bubble(int(v), ChatTail.line_for_tail(int(t), String(s))))
|
||||
# entity_moved 不强依赖:_process 每帧统一插值。留个钩子便于调试。
|
||||
client.entity_moved.connect(_on_moved)
|
||||
client.vitals_changed.connect(_on_vitals)
|
||||
@@ -103,6 +140,12 @@ 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)
|
||||
# §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))
|
||||
|
||||
# 已在局内(重连 / setup 是协程,进来时 spawn burst 已被 pump 抽干)——
|
||||
# 把当前所有实体补建一遍,别漏掉进游戏那一批怪 / NPC / 玩家。
|
||||
@@ -134,6 +177,83 @@ func set_local_vid(vid: int) -> void:
|
||||
func set_local_node(node: Node3D) -> void:
|
||||
_local_node = node
|
||||
|
||||
# game.py ShowName/HideName toggles actor name plates without changing the
|
||||
# network data layer. Shop/PVP labels and chat bubbles remain independent.
|
||||
# LALT 按 / 抬 -> game.py ShowNameFlag(__IsShowName 的一半)。
|
||||
func set_names_visible(visible: bool) -> void:
|
||||
_alt_held = visible
|
||||
_refresh_all_name_visibility()
|
||||
|
||||
func names_visible() -> bool:
|
||||
return _alt_held
|
||||
|
||||
# uigameoption「总显示名字」radio -> CPythonSystem::SetAlwaysShowNameFlag(__IsShowName 的另一半)。
|
||||
func set_always_show_name(on: bool) -> void:
|
||||
_always_show_name = on
|
||||
_refresh_all_name_visibility()
|
||||
|
||||
# targetBoard.GetTargetVID():目标始终强显名字(game.py OnRender ShowCharacterTextTail(targetVID))。
|
||||
func set_target_vid(vid: int) -> void:
|
||||
if _target_vid == vid:
|
||||
return
|
||||
var prev := _target_vid
|
||||
_target_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)
|
||||
|
||||
# game.py OnRender:本地主角平面坐标(ShowAllTextTail 的距离中心);未知返回 (INF,INF)。
|
||||
func _main_planar_cm() -> Vector2:
|
||||
if client and client.has_method("get_entity") and _main_vid != 0:
|
||||
var m: Dictionary = client.get_entity(_main_vid)
|
||||
if not m.is_empty() and m.get("pos_cm", null) is Vector3:
|
||||
var mc: Vector3 = m["pos_cm"]
|
||||
return Vector2(mc.x, mc.y)
|
||||
return Vector2(INF, INF)
|
||||
|
||||
# name_show.name_visible_for 的 net_world 侧组装:forced(target / 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):
|
||||
return false
|
||||
var vid := int(root.get_meta("vid", 0))
|
||||
var e: Dictionary = {}
|
||||
if vid != 0 and client and client.has_method("get_entity"):
|
||||
e = client.get_entity(vid)
|
||||
var dead := bool(e.get("dead", false))
|
||||
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
|
||||
var ct: Variant = _chat_tails.get(vid, null)
|
||||
if ct is Dictionary and bool(ct.get("name_flag", false)):
|
||||
forced = true
|
||||
var show_name := NameShow.is_show_name(_always_show_name, _alt_held)
|
||||
var dist := 0.0 # 主角位置未知 -> 不按距离裁剪(fail-open,同 _is_visible_entity)
|
||||
if not e.is_empty() and e.get("pos_cm", null) is Vector3:
|
||||
var mc := _main_planar_cm()
|
||||
if mc.x != INF:
|
||||
dist = NameShow.planar_distance_cm(_planar_cm(e), mc)
|
||||
return NameShow.name_visible_for(forced, show_name, dist, alive, is_wall, alive)
|
||||
|
||||
func _set_name_visibility(root: Node3D) -> void:
|
||||
if root == null or not is_instance_valid(root):
|
||||
return
|
||||
var shown := _name_shown(root)
|
||||
var tag := root.get_node_or_null("Label3D") as Label3D
|
||||
if tag:
|
||||
tag.visible = shown
|
||||
# §8.8 称号 / 等级 / 公会名子标签随主名字整体开关;各自「是否有内容」仍由
|
||||
# _apply_text_tail 按 RefreshTextTail 决定。
|
||||
for sub in ["GuildTag", "TitleTag", "LevelTag"]:
|
||||
var st := root.get_node_or_null(sub) as Label3D
|
||||
if st:
|
||||
st.visible = shown and st.text != ""
|
||||
|
||||
# ClientVS22 destroys the network actor manager when Loading starts. This is
|
||||
# called from M2Client.world_reset before the replacement connection sends its
|
||||
# first GC_MAIN_CHARACTER/GC_CHARACTER_ADD burst.
|
||||
@@ -202,10 +322,15 @@ func _on_spawn(d: Dictionary) -> void:
|
||||
node.set_meta("vid", vid)
|
||||
node.set_meta("func", int(d.get("func", FUNC_WAIT)))
|
||||
node.set_meta("mount_vnum", int(d.get("mount_vnum", 0)))
|
||||
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
|
||||
_set_name_visibility(node)
|
||||
_refresh_shop_sign(node, str(d.get("shop_sign", "")))
|
||||
_refresh_pvp_tag(vid)
|
||||
_apply_field_updates(node, d)
|
||||
_apply_name_color(vid)
|
||||
_apply_text_tail(vid)
|
||||
entity_added.emit(node, vid)
|
||||
if vid == _main_vid:
|
||||
main_entity_ready.emit(node, vid)
|
||||
@@ -223,6 +348,8 @@ func _on_info(vid: int, d: Dictionary) -> void:
|
||||
n.set_display_name(nm)
|
||||
_refresh_shop_sign(n, str(d.get("shop_sign", "")))
|
||||
_apply_field_updates(n, d)
|
||||
_apply_name_color(vid)
|
||||
_apply_text_tail(vid)
|
||||
|
||||
# §2.5: push the mutable entity fields onto a live view in the exact order the
|
||||
# reference client uses (CNetworkActorManager refresh -> NetworkActorManager.cpp
|
||||
@@ -230,12 +357,18 @@ func _on_info(vid: int, d: Dictionary) -> void:
|
||||
# SetAffectFlagContainer -> SetMoveSpeed -> SetAttackSpeed -> SetAlignment ->
|
||||
# SetPKMode -> SetStateFlags. Every setter is optional: the placeholder capsule
|
||||
# and views that do not model a field simply skip it.
|
||||
#
|
||||
# §4.1 修改 5(远端换装):armor/weapon/hair 字段顺序已 1:1(上面三行)。变身 / 时装
|
||||
# 遮挡由模型侧统一处理 —— 真模型(PlayerView + EquipModel)在 refresh() 里过
|
||||
# part_hiding.gd(见 equip_model.gd)。远端 PC 由 RemotePlayerView 消费
|
||||
# awPart[CHR_EQUIPPART_ARMOR..HAIR];本地玩家仍由 EquipModel 读取 get_equipment()。
|
||||
func _apply_field_updates(n: Node3D, d: Dictionary) -> void:
|
||||
var parts: Variant = d.get("parts", [])
|
||||
if parts is Array and parts.size() >= 3:
|
||||
if parts is Array and parts.size() >= 4:
|
||||
_set_wear_slot(n, "set_armor", 0, int(parts[0]))
|
||||
_set_wear_slot(n, "set_weapon", 1, int(parts[1]))
|
||||
_set_wear_slot(n, "set_hair", 2, int(parts[2]))
|
||||
_set_wear_slot(n, "set_head", 2, int(parts[2]))
|
||||
_set_wear_slot(n, "set_hair", 3, int(parts[3]))
|
||||
if n.has_method("set_guild"):
|
||||
n.call("set_guild", int(d.get("guild", 0)))
|
||||
if n.has_method("set_affect_flags"):
|
||||
@@ -366,9 +499,167 @@ func _display_name(d: Dictionary) -> String:
|
||||
return r
|
||||
return str(d.get("name", ""))
|
||||
|
||||
# --- §8.8 头顶名字色(CInstanceBase::GetNameColor)--------------------------------
|
||||
# net_world 直接持有 M2Client,能自建 name_color_index 需要的会话上下文
|
||||
# (net_play._attack_ctx 同款)。kind_resolver 由 game_scene 注入 net_play._entity_kind
|
||||
# 以补本 fork「NPC/怪 bType 都是 0」的分类缺口;未注入时退化到 ch_type。
|
||||
|
||||
func _entity_kind_of(d: Dictionary) -> int:
|
||||
if kind_resolver.is_valid():
|
||||
return int(kind_resolver.call(d))
|
||||
return int(d.get("ch_type", 0))
|
||||
|
||||
func _name_color_ctx() -> Dictionary:
|
||||
var duel: Dictionary = client.get_duel() if client and client.has_method("get_duel") else {}
|
||||
var duel_mode := EntityRules.DUEL_NONE
|
||||
if bool(duel.get("active", false)):
|
||||
duel_mode = EntityRules.DUEL_CANNOTATTACK if bool(duel.get("cannot_attack", false)) \
|
||||
else EntityRules.DUEL_START
|
||||
var pvp_pairs: Array = []
|
||||
if client and client.has_method("get_pvp_relations"):
|
||||
for r in client.get_pvp_relations():
|
||||
if int(r.get("mode", 0)) != 0:
|
||||
pvp_pairs.append([int(r.get("src_vid", 0)), int(r.get("dst_vid", 0))])
|
||||
var gvg_pairs: Array = []
|
||||
if client and client.has_method("get_guild_wars"):
|
||||
for w in client.get_guild_wars():
|
||||
gvg_pairs.append([int(w.get("src", 0)), int(w.get("dst", 0))])
|
||||
var party_vids: Array = []
|
||||
if client and client.has_method("get_party"):
|
||||
for m in client.get_party():
|
||||
party_vids.append(int(m.get("vid", 0)))
|
||||
return {
|
||||
"main_vid": _main_vid,
|
||||
"duel_mode": duel_mode,
|
||||
"duel_opponents": duel.get("opponents", []),
|
||||
"pvp_pairs": pvp_pairs,
|
||||
"gvg_pairs": gvg_pairs,
|
||||
"party_vids": party_vids,
|
||||
}
|
||||
|
||||
func _main_name_snapshot() -> Dictionary:
|
||||
if _main_vid == 0 or client == null or not client.has_method("get_entity"):
|
||||
return {}
|
||||
var m: Dictionary = client.get_entity(_main_vid)
|
||||
if m.is_empty():
|
||||
return {}
|
||||
m = m.duplicate()
|
||||
m["kind"] = _entity_kind_of(m)
|
||||
return m
|
||||
|
||||
func _apply_name_color(vid: int) -> void:
|
||||
if vid == 0:
|
||||
return
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n == null or not is_instance_valid(n) or not n.has_node("Label3D"):
|
||||
return
|
||||
if client == null or not client.has_method("get_entity"):
|
||||
return
|
||||
var e: Dictionary = client.get_entity(vid)
|
||||
if e.is_empty():
|
||||
return
|
||||
var self_e := e.duplicate()
|
||||
self_e["kind"] = _entity_kind_of(e)
|
||||
var col := NameColor.name_color(self_e, _main_name_snapshot(), _name_color_ctx())
|
||||
(n.get_node("Label3D") as Label3D).modulate = col
|
||||
|
||||
func _retint_all_names() -> void:
|
||||
if client == null or not client.has_method("get_entity"):
|
||||
return
|
||||
var ctx := _name_color_ctx()
|
||||
var main_e := _main_name_snapshot()
|
||||
for vid in _by_vid.keys():
|
||||
var n: Node3D = _by_vid[vid]
|
||||
if not is_instance_valid(n) or not n.has_node("Label3D"):
|
||||
continue
|
||||
var e: Dictionary = client.get_entity(int(vid))
|
||||
if e.is_empty():
|
||||
continue
|
||||
var self_e := e.duplicate()
|
||||
self_e["kind"] = _entity_kind_of(e)
|
||||
(n.get_node("Label3D") as Label3D).modulate = NameColor.name_color(self_e, main_e, ctx)
|
||||
|
||||
# constinfo.SET_CHRNAME_COLOR_INDEX 对应的运行期开关(默认关)。改后立即整表重刷。
|
||||
func set_empire_name_mode(enabled: bool) -> void:
|
||||
NameColor.set_empire_name_mode(enabled)
|
||||
_retint_all_names()
|
||||
|
||||
# --- §8.8 头顶「公会名 / 称号 / 等级」标签(CInstanceBase::RefreshTextTail 系)--------
|
||||
# _apply_name_color 的姊妹:把 self_e 快照喂给 text_tail.gd,落到 GuildTag/TitleTag/LevelTag。
|
||||
|
||||
func _resolve_title_name(grade: int) -> String:
|
||||
if title_name_resolver.is_valid():
|
||||
var s: String = str(title_name_resolver.call(grade))
|
||||
if s != "":
|
||||
return s
|
||||
return TextTail.title_name_key(grade) # seam ⑧:未注入 Locale -> 裸 key
|
||||
|
||||
func _resolve_guild_name(guild_id: int) -> String:
|
||||
if guild_id != 0 and client and client.has_method("get_guild_name"):
|
||||
return str(client.get_guild_name(guild_id))
|
||||
return ""
|
||||
|
||||
func _apply_text_tail_to(n: Node3D, e: Dictionary) -> void:
|
||||
if n == null or not is_instance_valid(n):
|
||||
return
|
||||
var alignment := int(e.get("alignment", 0))
|
||||
var level := int(e.get("level", 0))
|
||||
var guild_id := int(e.get("guild", 0))
|
||||
var shown := _name_shown(n)
|
||||
|
||||
var gtag := n.get_node_or_null("GuildTag") as Label3D
|
||||
if gtag:
|
||||
var g := TextTail.refresh_guild(guild_id, _resolve_guild_name(guild_id))
|
||||
gtag.text = g["name"] if g["show_guild"] else ""
|
||||
gtag.modulate = g["color"]
|
||||
gtag.visible = shown and gtag.text != ""
|
||||
|
||||
var ttag := n.get_node_or_null("TitleTag") as Label3D
|
||||
if ttag:
|
||||
var t := TextTail.refresh_title(alignment)
|
||||
ttag.text = _resolve_title_name(int(t["grade"])) if t["show_title"] else ""
|
||||
ttag.modulate = t["color"]
|
||||
ttag.visible = shown and ttag.text != ""
|
||||
|
||||
var ltag := n.get_node_or_null("LevelTag") as Label3D
|
||||
if ltag:
|
||||
var lv := TextTail.refresh_level(level)
|
||||
ltag.text = lv["text"] if lv["show_level"] else ""
|
||||
ltag.modulate = lv["color"]
|
||||
ltag.visible = shown and ltag.text != ""
|
||||
|
||||
func _apply_text_tail(vid: int) -> void:
|
||||
if vid == 0 or client == null or not client.has_method("get_entity"):
|
||||
return
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n == null or not is_instance_valid(n):
|
||||
return
|
||||
var e: Dictionary = client.get_entity(vid)
|
||||
if e.is_empty():
|
||||
return
|
||||
_apply_text_tail_to(n, e)
|
||||
|
||||
func _retint_all_text_tails() -> void:
|
||||
if client == null or not client.has_method("get_entity"):
|
||||
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 e.is_empty():
|
||||
continue
|
||||
_apply_text_tail_to(n, e)
|
||||
|
||||
# CPythonTextTail::EnablePKTitle(game.py:155 用 constInfo.PVPMODE_ENABLE 初始化)。
|
||||
func set_pk_title_enabled(enabled: bool) -> void:
|
||||
TextTail.set_pk_title_enabled(enabled)
|
||||
_retint_all_text_tails()
|
||||
|
||||
func _on_despawn(vid: int) -> void:
|
||||
_duel_opponents.erase(vid)
|
||||
_fading.erase(vid)
|
||||
_chat_tails.erase(vid)
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n:
|
||||
_by_vid.erase(vid)
|
||||
@@ -380,6 +671,8 @@ func _on_main_set(vid: int) -> void:
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n:
|
||||
main_entity_ready.emit(n, vid)
|
||||
# 主角变化会改写所有 PC 的相对名字色(同帝国 / PVP / 决斗 / 队伍判定都相对主角)。
|
||||
_retint_all_names()
|
||||
|
||||
func _on_moved(_vid: int) -> void:
|
||||
pass # 插值在 _process 里统一做
|
||||
@@ -425,8 +718,6 @@ func _on_motion(vid: int, victim_vid: int, motion: int) -> void:
|
||||
n.call("set_anim_state", state)
|
||||
else:
|
||||
n.set_meta("motion_state", state)
|
||||
if n.has_node("Label3D"):
|
||||
(n.get_node("Label3D") as Label3D).modulate = _state_tint(state)
|
||||
|
||||
# GC_PVP: 显示当前与任一角色的挑战 / 战斗 / 复仇关系。原客户端同时会影响
|
||||
# TargetBoard 和名字颜色;这里先提供始终可见的世界标签,避免关系状态无表现。
|
||||
@@ -440,6 +731,8 @@ func _on_pvp_changed(src_vid: int, dst_vid: int, mode: int) -> void:
|
||||
_pvp_relations[key] = {"src_vid": src_vid, "dst_vid": dst_vid, "mode": mode}
|
||||
_refresh_pvp_tag(src_vid)
|
||||
_refresh_pvp_tag(dst_vid)
|
||||
_apply_name_color(src_vid)
|
||||
_apply_name_color(dst_vid)
|
||||
|
||||
func _pvp_key(a: int, b: int) -> String:
|
||||
return "%d:%d" % [min(a, b), max(a, b)]
|
||||
@@ -496,34 +789,60 @@ func _on_duel_changed(duel: Dictionary) -> void:
|
||||
_duel_opponents[opponent] = true
|
||||
for vid in _by_vid.keys():
|
||||
_refresh_pvp_tag(int(vid))
|
||||
_apply_name_color(int(vid))
|
||||
|
||||
# 头顶聊天气泡(~4s 淡出)。本地玩家 vid 走 main_bubble 信号让上层处理。
|
||||
# 头顶聊天 / 信息尾标(CPythonTextTail::RegisterChatTail / RegisterInfoTail)。
|
||||
# 每 vid 一条 "bubble" 子节点,LivingTime 到点(默认 5s)由 _expire_chat_tails 硬删——
|
||||
# 参考端无淡出。本地玩家 vid 走 main_bubble 信号让上层(game_scene._player_bubble)处理。
|
||||
signal main_bubble(text: String)
|
||||
|
||||
func _bubble(vid: int, text: String) -> void:
|
||||
# CPythonTextTail::RegisterChatTail —— 普通聊天尾标(白、bNameFlag TRUE)
|
||||
if vid == _local_vid or vid == 0:
|
||||
main_bubble.emit(text)
|
||||
return
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n == null:
|
||||
return
|
||||
var old := n.get_node_or_null("bubble")
|
||||
if old:
|
||||
old.queue_free()
|
||||
var lbl := Label3D.new()
|
||||
lbl.name = "bubble"
|
||||
lbl.text = text
|
||||
lbl.position.y = 2.35
|
||||
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
lbl.no_depth_test = true
|
||||
lbl.pixel_size = 0.0055
|
||||
lbl.modulate = Color(1, 1, 1)
|
||||
lbl.outline_size = 6
|
||||
n.add_child(lbl)
|
||||
var tw := create_tween()
|
||||
tw.tween_interval(3.0)
|
||||
tw.tween_property(lbl, "modulate:a", 0.0, 1.0)
|
||||
tw.tween_callback(lbl.queue_free)
|
||||
_apply_chat_tail(n, ChatTail.register_chat(_chat_tails, vid, text, Time.get_ticks_msec()))
|
||||
|
||||
func register_info_tail(vid: int, text: String) -> void:
|
||||
# CPythonTextTail::RegisterInfoTail —— 信息尾标(淡红、bNameFlag FALSE)。
|
||||
# game.py:930 / :936 技能 / 射击错误尾标走这里;与聊天尾标共用同一张 _chat_tails。
|
||||
if vid == _local_vid or vid == 0:
|
||||
main_bubble.emit(text)
|
||||
return
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n == null:
|
||||
return
|
||||
_apply_chat_tail(n, ChatTail.register_info(_chat_tails, vid, text, Time.get_ticks_msec()))
|
||||
|
||||
func _apply_chat_tail(n: Node3D, tail: Dictionary) -> void:
|
||||
var lbl := n.get_node_or_null("bubble") as Label3D
|
||||
if lbl == null:
|
||||
lbl = Label3D.new()
|
||||
lbl.name = "bubble"
|
||||
lbl.position.y = 2.35
|
||||
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
lbl.no_depth_test = true
|
||||
lbl.pixel_size = 0.0055
|
||||
lbl.outline_size = 6
|
||||
n.add_child(lbl)
|
||||
lbl.text = String(tail.get("text", ""))
|
||||
lbl.modulate = tail.get("color", ChatTail.CHAT_COLOR)
|
||||
# UpdateAllTextTail:chat 尾标 bNameFlag TRUE 时强制显示该角色名字(不受 3500 限制)。
|
||||
_set_name_visibility(n)
|
||||
|
||||
func _expire_chat_tails() -> void:
|
||||
# UpdateAllTextTail:LivingTime < now 即删除对应 "bubble" 节点
|
||||
for vid in ChatTail.expire(_chat_tails, Time.get_ticks_msec()):
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n:
|
||||
var lbl := n.get_node_or_null("bubble")
|
||||
if lbl:
|
||||
lbl.queue_free()
|
||||
# 尾标没了 -> bNameFlag 强显撤销,名字回到 __IsShowName / 距离裁决。
|
||||
_set_name_visibility(n)
|
||||
|
||||
func _on_vitals(vid: int) -> void:
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
@@ -549,12 +868,15 @@ func _on_dead(vid: int) -> void:
|
||||
if n.has_method("set_anim_state"):
|
||||
n.call("set_anim_state", "dead")
|
||||
|
||||
# P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体。原客户端在
|
||||
# GC_CREATE_FLY 之前可能只收到 FLY_TARGETING(target VID=0 时是坐标),所以
|
||||
# 这里也消费 shooter 的目标队列,避免远端技能没有终点而被静默丢弃。
|
||||
# P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体 / 坐标。原客户端在
|
||||
# GC_CREATE_FLY 之前可能只收到 FLY_TARGETING(target VID=0 时是坐标),所以这里也消费
|
||||
# shooter 的目标队列,避免远端技能没有终点被静默丢弃。§3.6:交给 FlyManager 跑真实弹道
|
||||
# (CFlyingManager::CreateIndexedFly 的 INDEX_FLY_TYPE_NORMAL 分支:起点取射手像素位、
|
||||
# 终点取目标 actor、canAttack=false —— 命中 / 伤害是服务端权威,客户端只做可视 + 命中时机)。
|
||||
func _on_fly(_type: int, start_vid: int, end_vid: int) -> void:
|
||||
var a: Vector3 = _entity_pos(start_vid)
|
||||
var b: Vector3 = _entity_pos(end_vid)
|
||||
var target_node: Node3D = _by_vid.get(end_vid, null)
|
||||
var shooter: Node3D = _by_vid.get(start_vid, null)
|
||||
if b == Vector3.INF and shooter:
|
||||
var target: Dictionary = {}
|
||||
@@ -565,28 +887,28 @@ func _on_fly(_type: int, start_vid: int, end_vid: int) -> void:
|
||||
var target_vid := int(target.get("vid", 0))
|
||||
if target_vid != 0:
|
||||
b = _entity_pos(target_vid)
|
||||
target_node = _by_vid.get(target_vid, null)
|
||||
else:
|
||||
var target_cm: Variant = target.get("cm", null)
|
||||
if target_cm is Vector2:
|
||||
b = _server_cm_to_world(target_cm)
|
||||
target_node = null
|
||||
if a == Vector3.INF or b == Vector3.INF or parent == null:
|
||||
return
|
||||
var proj := MeshInstance3D.new()
|
||||
var sm := SphereMesh.new()
|
||||
sm.radius = 0.08
|
||||
sm.height = 0.16
|
||||
proj.mesh = sm
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = Color(1.0, 0.9, 0.4)
|
||||
mat.emission_enabled = true
|
||||
mat.emission = Color(0.9, 0.7, 0.2)
|
||||
proj.material_override = mat
|
||||
parent.add_child(proj)
|
||||
proj.global_position = a + Vector3(0, 1.0, 0)
|
||||
var dur := maxf(0.15, a.distance_to(b) / 40.0)
|
||||
var tw := create_tween()
|
||||
tw.tween_property(proj, "global_position", b + Vector3(0, 1.0, 0), dur)
|
||||
tw.tween_callback(proj.queue_free)
|
||||
if _fly_mgr == null:
|
||||
return
|
||||
_fly_mgr.parent = parent
|
||||
_fly_mgr.world = world
|
||||
var start_w := a + Vector3(0, 1.0, 0)
|
||||
var goal_w := b + Vector3(0, 1.0, 0)
|
||||
# POC 无 .fly 加载器:合成一组弓箭弹道参数(换算成米),其余留 CFlyingData 默认。
|
||||
var d = FlyObject.FlyData.new()
|
||||
d.init_vel = 45.0 # ~4500 cm/s 的箭速
|
||||
d.flat_range = maxf(start_w.distance_to(goal_w) * 1.5, 30.0)
|
||||
d.bomb_range = 0.6 # 60 cm 命中球
|
||||
d.pierce_count = 0
|
||||
var tgt: Variant = target_node if is_instance_valid(target_node) else goal_w
|
||||
_fly_mgr.spawn(start_w, tgt, false, d, _type)
|
||||
|
||||
# GC_FLY_TARGETING / GC_ADD_FLY_TARGETING — retain the authoritative target on
|
||||
# the shooter node so a later projectile cue can use it even before a target
|
||||
@@ -688,6 +1010,17 @@ func _process(dt: float) -> void:
|
||||
if client == null or parent == null:
|
||||
return
|
||||
_update_visibility()
|
||||
# §8.8:名字色随队伍 / 帝国名色模式 / PVP·决斗边沿变化——用 ~2Hz 节流整表刷新
|
||||
# 兜底(单发路径已覆盖 spawn/info/pvp/duel/main_set;参考端每帧都算 GetNameColor)。
|
||||
_name_color_accum += dt
|
||||
if _name_color_accum >= 0.5:
|
||||
_name_color_accum = 0.0
|
||||
_retint_all_names()
|
||||
_retint_all_text_tails()
|
||||
_expire_chat_tails()
|
||||
# §8.4 / §8.6:ShowAllTextTail 的 3500 平面距随主角 / 实体移动变化 —— 逐帧太密,
|
||||
# 用同一 ~2Hz 节流兜底(LALT / 选项 / 选中 / 尾标切换已各自即时刷新)。
|
||||
_refresh_all_name_visibility()
|
||||
for vid in _by_vid.keys():
|
||||
var n: Node3D = _by_vid[vid]
|
||||
if not is_instance_valid(n):
|
||||
@@ -696,11 +1029,11 @@ func _process(dt: float) -> void:
|
||||
var e: Dictionary = client.get_entity(vid)
|
||||
if e.is_empty():
|
||||
continue
|
||||
# §3.2:位置插值现在完全在 C++ EntityStore::tick() 里做(命令队列 +
|
||||
# SNetworkActorData::UpdatePosition 式线性插值),这里只跟随 e.pos,不再
|
||||
# 叠第二层 follow_lerp——否则会把 C++ 的到达时机拖慢、和动作事件脱节。
|
||||
var want := _grounded(e.get("pos", n.position))
|
||||
if n.position.distance_to(want) > snap_dist:
|
||||
n.position = want
|
||||
else:
|
||||
n.position = n.position.lerp(want, clampf(follow_lerp * dt, 0.0, 1.0))
|
||||
n.position = want
|
||||
# 朝向:Metin2 heading(度) -> Godot yaw。经验换算,真机再校准。
|
||||
var yaw := MapCoord.heading_to_yaw(float(e.get("angle_deg", 0.0)))
|
||||
n.rotation.y = lerp_angle(n.rotation.y, yaw, clampf(10.0 * dt, 0.0, 1.0))
|
||||
@@ -727,16 +1060,8 @@ func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1) -> void:
|
||||
state = "emotion"
|
||||
if n.has_method("set_anim_state"):
|
||||
n.call("set_anim_state", state)
|
||||
elif n.has_node("Label3D"):
|
||||
(n.get_node("Label3D") as Label3D).modulate = _state_tint(state)
|
||||
|
||||
func _state_tint(s: String) -> Color:
|
||||
match s:
|
||||
"walk": return Color(0.7, 0.9, 1.0)
|
||||
"run": return Color(0.6, 1.0, 0.6)
|
||||
"attack": return Color(1.0, 0.5, 0.4)
|
||||
"skill": return Color(1.0, 0.8, 0.3)
|
||||
_: return Color(1, 1, 1)
|
||||
# 占位胶囊(无 set_anim_state)不再借头顶名字 Label3D 的 modulate 表现动作态——
|
||||
# 该 modulate 现在专属 §8.8 名字色(GetNameColor)。
|
||||
|
||||
func _grounded(p: Variant) -> Vector3:
|
||||
# p 是 M2Client 的网络帧 pos —— 先转到 Metin2World 本地帧,再贴地。
|
||||
@@ -771,7 +1096,16 @@ func _attach_nameplate(root: Node3D, d: Dictionary) -> void:
|
||||
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
tag.no_depth_test = true
|
||||
tag.pixel_size = 0.006
|
||||
tag.visible = false # _on_spawn 随后 _set_name_visibility(node) 按 __IsShowName / 距离定夺
|
||||
root.add_child(tag)
|
||||
# §8.8 头顶「公会名 / 称号 / 等级」子标签(CPythonTextTail 的 pGuildNameTextInstance /
|
||||
# pTitleTextInstance / pLevelTextInstance)。参考端是屏幕空间:公会名在名字上方
|
||||
# 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()
|
||||
@@ -783,6 +1117,18 @@ func _attach_nameplate(root: Node3D, d: Dictionary) -> void:
|
||||
bar.scale.x = 1.0
|
||||
root.add_child(bar)
|
||||
|
||||
func _mk_sub_tag(sub_name: String, y: float, col: Color) -> Label3D:
|
||||
var t := Label3D.new()
|
||||
t.name = sub_name
|
||||
t.text = ""
|
||||
t.position.y = y
|
||||
t.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
t.no_depth_test = true
|
||||
t.pixel_size = 0.005
|
||||
t.modulate = col
|
||||
t.visible = false
|
||||
return t
|
||||
|
||||
static var _white_tex: Texture2D
|
||||
static func _white_1px() -> Texture2D:
|
||||
if _white_tex == null:
|
||||
|
||||
Reference in New Issue
Block a user