- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay 只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续 运行并记 BLOCKED;写本批次 release-manifest.json 后聚合 - run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理); soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数) - 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试 - forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别 - 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求 - .gitignore 排除本地场景配置、凭据文件与运行输出 离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS, node 夹具测试 PASS。未联网运行,未重建候选包。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
1465 lines
61 KiB
GDScript
1465 lines
61 KiB
GDScript
# NetWorld —— 把 M2Client 的网络实体镜像成 Godot 场景节点。
|
||
#
|
||
# 消费 M2Client 的 entity_spawned / entity_despawned / entity_moved /
|
||
# entity_main_set / chat 信号:每个 vid 一个子节点,位置向 client.get_entity(vid).pos
|
||
# 平滑逼近,rotation.y 取自 angle_deg,动画状态取自 func(FUNC_*)。
|
||
#
|
||
# 默认生成「占位胶囊 + 名字牌」(快、无依赖,先跑通同步/插值/状态循环)。
|
||
# 要换成真模型:set_model_factory(func(entity_dict) -> Node3D),返回的节点若有
|
||
# set_anim_state(name:String) 方法就会被驱动。
|
||
#
|
||
# 用法:
|
||
# var nw := preload("res://net_world.gd").new()
|
||
# add_child(nw)
|
||
# nw.setup(m2client, self) # self = 实体挂载父节点(Node3D)
|
||
# nw.world = metin2_world # 可选:贴地
|
||
# 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 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)
|
||
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)
|
||
signal damage_number(vid: int, amount: int, crit: bool, dodge: bool)
|
||
|
||
# CInstanceBase::FUNC_*
|
||
const FUNC_WAIT := 0
|
||
const FUNC_MOVE := 1
|
||
const FUNC_ATTACK := 2
|
||
const FUNC_COMBO := 3
|
||
const FUNC_MOB_SKILL := 4
|
||
const FUNC_EMOTION := 5
|
||
const FUNC_SKILL := 0x80
|
||
|
||
# EDamageFlag / IsShowDamage / ProcessDamage 分类见 damage_effect.gd(DamageEffect.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
|
||
# entity is visible: it is the main character, force-visible, a wall, or within
|
||
# CHAR_STAGE_VIEW_BOUND of the main character.
|
||
# CPythonCharacterManager.cpp:13 -> CHAR_STAGE_VIEW_BOUND = 200*100 (cm; px==cm)
|
||
# PythonCharacterManager.cpp:182 -> cull past (CHAR_STAGE_VIEW_BOUND + 10)
|
||
const CHAR_STAGE_VIEW_BOUND := 200 * 100
|
||
# CInstanceBase AFFECT enum, ordinal 7 (bit 7) == AFFECT_SHOW_ALWAYS. The 40250
|
||
# ClientVS22 tree keeps the enum in AffectFlagContainer.h and does not spell the
|
||
# member out; this mirrors the m2dev sibling source (InstanceBase.h) and is the
|
||
# force-visible bit checked by CNetworkActorManager::__IsVisibleActor.
|
||
const AFFECT_SHOW_ALWAYS := 1 << 7
|
||
# CInstanceBase::IsWall race ids (InstanceBase.cpp) — always mirrored regardless
|
||
# of distance so map geometry never pops.
|
||
const WALL_RACES := [14201, 14202, 14203, 14204]
|
||
|
||
var client: Node # M2Client
|
||
var parent: Node3D # 实体挂这下面
|
||
var world: Node # Metin2World(可空,用 sample_height 贴地)
|
||
# §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
|
||
var _local_vid := 0 # 由 net_play 设:这个 vid 由本地 player 代表,不生成节点
|
||
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 +
|
||
# _hover_vid(game.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 射线拾到的角色 vid(0 = 无)
|
||
# §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_bDamageEffectType(SELFDAMAGE / SELFDAMAGE2 交替)
|
||
var _fly_mgr: Node # FlyManager(fly_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
|
||
# 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
|
||
parent = mount
|
||
client.entity_spawned.connect(_on_spawn)
|
||
client.entity_despawned.connect(_on_despawn)
|
||
client.entity_main_set.connect(_on_main_set)
|
||
if client.has_signal("entity_info"):
|
||
client.entity_info.connect(_on_info)
|
||
client.chat.connect(func(t, v, s):
|
||
chat_line.emit(t, v, 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)
|
||
client.entity_dead.connect(_on_dead)
|
||
client.damage.connect(_on_damage)
|
||
if client.has_signal("motion"):
|
||
client.motion.connect(_on_motion)
|
||
if client.has_signal("dig_motion"):
|
||
client.dig_motion.connect(_on_dig_motion)
|
||
if client.has_signal("fly_cue"):
|
||
client.fly_cue.connect(_on_fly)
|
||
if client.has_signal("fly_targeting"):
|
||
client.fly_targeting.connect(_on_fly_targeting)
|
||
if client.has_signal("fishing_event"):
|
||
client.fishing_event.connect(_on_fishing_event)
|
||
if client.has_signal("pvp_changed"):
|
||
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 / 玩家。
|
||
func catch_up() -> void:
|
||
if client == null or not client.has_method("get_entities"):
|
||
return
|
||
for e in client.get_entities():
|
||
var vid := int(e.get("vid", 0))
|
||
if vid != 0 and not _by_vid.has(vid) and vid != _local_vid:
|
||
_on_spawn(e)
|
||
if client.has_method("get_pvp_relations"):
|
||
for relation in client.get_pvp_relations():
|
||
_on_pvp_changed(int(relation.get("src_vid", 0)), int(relation.get("dst_vid", 0)),
|
||
int(relation.get("mode", 0)))
|
||
if client.has_method("get_duel"):
|
||
_on_duel_changed(client.get_duel())
|
||
|
||
func set_model_factory(f: Callable) -> void:
|
||
_model_factory = f
|
||
# The game server can send the initial actor burst before GameScene has
|
||
# finished loading mob/item proto tables. Those actors were necessarily made
|
||
# as capsules; once the real factory is available, replace them from the
|
||
# authoritative EntityStore rows instead of leaving mixed placeholder/GR2
|
||
# models on screen.
|
||
_refresh_existing_model_views()
|
||
|
||
func _refresh_existing_model_views() -> void:
|
||
if not _model_factory.is_valid() or client == null or not client.has_method("get_entity"):
|
||
return
|
||
for vid in _by_vid.keys().duplicate():
|
||
var id := int(vid)
|
||
if id == _local_vid:
|
||
continue
|
||
var d: Dictionary = client.get_entity(id)
|
||
if not d.is_empty():
|
||
_on_spawn(d)
|
||
|
||
# 本地玩家用 player_controller 的节点代表,这里就不要再画一个。
|
||
func set_local_vid(vid: int) -> void:
|
||
_local_vid = vid
|
||
var n: Node3D = _by_vid.get(vid, null)
|
||
if n:
|
||
_by_vid.erase(vid)
|
||
n.queue_free()
|
||
_remove_hp_bar(vid)
|
||
|
||
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()
|
||
|
||
# uigameoption「伤害数字」radio -> CPythonSystem::SetShowDamageFlag(AddDamageEffect 的入队门)。
|
||
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:
|
||
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])
|
||
|
||
# game.py OnRender:wndMgr.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)
|
||
|
||
# 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 / 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):
|
||
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
|
||
# game.py OnRender:targetBoard.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
|
||
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 != ""
|
||
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
|
||
# first GC_MAIN_CHARACTER/GC_CHARACTER_ADD burst.
|
||
func clear_for_map_change() -> void:
|
||
for vid in _by_vid.keys():
|
||
var node: Node3D = _by_vid[vid]
|
||
if is_instance_valid(node):
|
||
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()
|
||
_duel_cannot_attack = false
|
||
|
||
func node_for(vid: int) -> Node3D:
|
||
return _by_vid.get(vid, null)
|
||
|
||
func main_node() -> Node3D:
|
||
return _by_vid.get(_main_vid, null)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _on_spawn(d: Dictionary) -> void:
|
||
var vid := int(d.get("vid", 0))
|
||
if vid == 0 or vid == _local_vid:
|
||
return
|
||
# §2.1: a node that is blending out is still "present" until the tween frees
|
||
# it — don't rebuild it mid-fade (mirrors GetInstancePtr/UpdateDeleting).
|
||
if _fading.has(vid):
|
||
return
|
||
# §2.3 one-overwrite rule: a fresh Spawn for a VID that already has a node
|
||
# destroys and recreates the node (the data row was whole-replaced). The one
|
||
# exception is a mount / dismount driven rebuild, which keeps the old world
|
||
# position (reference: kCreateData.m_lPosX = oldPos.x; m_lPosY = -oldPos.y —
|
||
# the Y flip is the server<->pixel frame, already applied, so in Godot world
|
||
# space this is just the old node's global_position).
|
||
var keep_pos: Variant = null
|
||
var existing: Node3D = _by_vid.get(vid, null)
|
||
if is_instance_valid(existing):
|
||
var old_mount := int(existing.get_meta("mount_vnum", 0))
|
||
var new_mount := int(d.get("mount_vnum", 0))
|
||
if (old_mount == 0) != (new_mount == 0):
|
||
keep_pos = existing.global_position
|
||
_by_vid.erase(vid)
|
||
existing.queue_free()
|
||
elif not _is_visible_entity(d):
|
||
# §2.1: the data row lives in EntityStore; the scene node is only built
|
||
# once the entity is in view. _update_visibility() creates it later when
|
||
# the main character moves into CHAR_STAGE_VIEW_BOUND.
|
||
return
|
||
var node: Node3D = null
|
||
if _model_factory.is_valid():
|
||
node = _model_factory.call(d)
|
||
if node == null:
|
||
node = _make_placeholder(d)
|
||
elif not node.has_node("Label3D"):
|
||
_attach_nameplate(node, d) # 真模型也要头顶名字 + HP 条
|
||
node.name = "e_%d" % vid
|
||
parent.add_child(node)
|
||
if keep_pos is Vector3:
|
||
node.global_position = keep_pos
|
||
else:
|
||
node.position = _grounded(d.get("pos", Vector3.ZERO))
|
||
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
|
||
_sync_hp_bar(vid, d)
|
||
_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)
|
||
|
||
# GC_CHAR_ADD_INFO 到了:刷新头顶名字 / HP 上限(节点已存在)。
|
||
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"):
|
||
(n.get_node("Label3D") as Label3D).text = nm
|
||
elif n.has_method("set_display_name"):
|
||
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
|
||
# :470): ChangeArmor -> ChangeWeapon -> ChangeHair -> ChangeGuild ->
|
||
# 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() >= 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_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"):
|
||
n.call("set_affect_flags", int(d.get("affect_flags", 0)))
|
||
elif n.has_method("set_affects"):
|
||
n.call("set_affects", int(d.get("affect_flags", 0)))
|
||
if n.has_method("set_move_speed"):
|
||
n.call("set_move_speed", int(d.get("moving_speed", 0)))
|
||
elif n.has_method("set_server_speed"):
|
||
n.call("set_server_speed", int(d.get("moving_speed", 0)))
|
||
if n.has_method("set_attack_speed"):
|
||
n.call("set_attack_speed", int(d.get("attack_speed", 0)))
|
||
if n.has_method("set_alignment"):
|
||
n.call("set_alignment", int(d.get("alignment", 0)))
|
||
if n.has_method("set_pk_mode"):
|
||
n.call("set_pk_mode", int(d.get("pk_mode", 0)))
|
||
if n.has_method("set_state_flags"):
|
||
n.call("set_state_flags", int(d.get("state_flags", 0)))
|
||
# keep the §2.3 mount-toggle rebuild key current
|
||
n.set_meta("mount_vnum", int(d.get("mount_vnum", n.get_meta("mount_vnum", 0))))
|
||
|
||
func _set_wear_slot(n: Node3D, dedicated: String, slot: int, vnum: int) -> void:
|
||
if n.has_method(dedicated):
|
||
n.call(dedicated, vnum)
|
||
elif n.has_method("set_wear"):
|
||
n.call("set_wear", slot, vnum)
|
||
|
||
# --- §2.1 per-frame visibility manager ------------------------------------------
|
||
# Mirrors CNetworkActorManager::__IsVisibleActor + the CPythonCharacterManager
|
||
# cull loop: every data-layer entity is checked each frame; a node is built when
|
||
# the entity comes into view and blended out when it leaves. Runs only once the
|
||
# main character's position is known (reference cull needs GetMainInstancePtr);
|
||
# until then the entity_spawned signal path keeps building nodes as before.
|
||
func _update_visibility() -> void:
|
||
if not client.has_method("get_entities") or _main_vid == 0:
|
||
return
|
||
var main: Dictionary = client.get_entity(_main_vid)
|
||
if main.is_empty() or not (main.get("pos_cm", null) is Vector3):
|
||
return
|
||
var mc: Vector3 = main["pos_cm"]
|
||
var main_cm := Vector2(mc.x, mc.y)
|
||
var cull := float(CHAR_STAGE_VIEW_BOUND + 10)
|
||
for e in client.get_entities():
|
||
var vid := int(e.get("vid", 0))
|
||
if vid == 0 or vid == _local_vid or _fading.has(vid):
|
||
continue
|
||
var has_node := _by_vid.has(vid)
|
||
var forced := vid == _main_vid or bool(e.get("is_main", false)) \
|
||
or _show_always(e) or _is_wall(int(e.get("race", 0)))
|
||
var dist := INF
|
||
if e.get("pos_cm", null) is Vector3:
|
||
dist = _planar_cm(e).distance_to(main_cm)
|
||
if forced or dist < CHAR_STAGE_VIEW_BOUND:
|
||
if not has_node:
|
||
_on_spawn(e)
|
||
elif has_node and dist > cull:
|
||
_fade_and_free(vid)
|
||
|
||
# Distance test used both by the per-frame manager and the _on_spawn gate.
|
||
func _is_visible_entity(e: Dictionary) -> bool:
|
||
var vid := int(e.get("vid", 0))
|
||
if vid == _main_vid or bool(e.get("is_main", false)):
|
||
return true
|
||
if _show_always(e) or _is_wall(int(e.get("race", 0))):
|
||
return true
|
||
# Fail open when the data layer can't be queried or the main character has not
|
||
# been located yet — keeps the spawn burst / fake-client tests behaving as
|
||
# before; real gating kicks in once M2Client reports pos_cm + a main vid.
|
||
if client == null or not client.has_method("get_entities") or _main_vid == 0:
|
||
return true
|
||
var main: Dictionary = client.get_entity(_main_vid)
|
||
if main.is_empty() or not (main.get("pos_cm", null) is Vector3) \
|
||
or not (e.get("pos_cm", null) is Vector3):
|
||
return true
|
||
var mc: Vector3 = main["pos_cm"]
|
||
return _planar_cm(e).distance_to(Vector2(mc.x, mc.y)) < CHAR_STAGE_VIEW_BOUND
|
||
|
||
func _planar_cm(e: Dictionary) -> Vector2:
|
||
var p: Variant = e.get("pos_cm", null)
|
||
return Vector2(p.x, p.y) if p is Vector3 else Vector2.ZERO
|
||
|
||
func _show_always(e: Dictionary) -> bool:
|
||
return (int(e.get("affect_flags", 0)) & AFFECT_SHOW_ALWAYS) != 0
|
||
|
||
func _is_wall(race: int) -> bool:
|
||
return race in WALL_RACES
|
||
|
||
# §2.1 cull: keep the data row, blend the node out. It stays discoverable via
|
||
# _fading (so it is not rebuilt mid-blend) until the tween frees it.
|
||
func _fade_and_free(vid: int) -> void:
|
||
var n: Node3D = _by_vid.get(vid, null)
|
||
_by_vid.erase(vid)
|
||
if not is_instance_valid(n):
|
||
_fading.erase(vid)
|
||
return
|
||
_fading[vid] = true
|
||
entity_removed.emit(vid)
|
||
var tw := create_tween()
|
||
tw.tween_property(n, "scale", Vector3.ONE * 0.01, 0.3)
|
||
tw.tween_callback(func() -> void:
|
||
if is_instance_valid(n):
|
||
n.queue_free()
|
||
_fading.erase(vid))
|
||
|
||
func _refresh_shop_sign(root: Node3D, sign: String) -> void:
|
||
var tag := root.get_node_or_null("ShopSign") as Label3D
|
||
var text := sign.strip_edges()
|
||
if text == "":
|
||
if tag:
|
||
tag.queue_free()
|
||
return
|
||
if tag == null:
|
||
tag = Label3D.new()
|
||
tag.name = "ShopSign"
|
||
tag.position.y = 3.0
|
||
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||
tag.no_depth_test = true
|
||
tag.pixel_size = 0.0055
|
||
tag.modulate = Color(1.0, 0.82, 0.25)
|
||
tag.outline_size = 8
|
||
root.add_child(tag)
|
||
tag.text = text
|
||
|
||
func _display_name(d: Dictionary) -> String:
|
||
if name_resolver.is_valid():
|
||
var r: String = name_resolver.call(d)
|
||
if r != "":
|
||
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 != ""
|
||
|
||
_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,增量 123;SUBTAG_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
|
||
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)
|
||
_remove_hp_bar(vid)
|
||
var n: Node3D = _by_vid.get(vid, null)
|
||
if n:
|
||
_by_vid.erase(vid)
|
||
n.queue_free()
|
||
entity_removed.emit(vid)
|
||
|
||
func _on_main_set(vid: int) -> void:
|
||
_main_vid = vid
|
||
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 里统一做
|
||
|
||
# GC_MOTION and ServerCommand emotion commands share CRaceMotionData IDs in
|
||
# the 40250 client. Keep the raw event visible to gameplay and ask real PC
|
||
# views to play the matching one-shot action; placeholders still retain the
|
||
# authoritative motion metadata and state tint.
|
||
func _on_motion(vid: int, victim_vid: int, motion: int) -> void:
|
||
var n: Node3D = _by_vid.get(vid, null)
|
||
if n == null and vid == _local_vid:
|
||
n = _local_node
|
||
motion_event.emit(vid, victim_vid, motion)
|
||
if n == null:
|
||
return
|
||
n.set_meta("last_motion", motion)
|
||
n.set_meta("motion_victim_vid", victim_vid)
|
||
var target: Node3D = _by_vid.get(victim_vid, null)
|
||
if target and target != n:
|
||
var flat := target.global_position
|
||
flat.y = n.global_position.y
|
||
if n.global_position.distance_squared_to(flat) > 0.001:
|
||
n.look_at(flat, Vector3.UP)
|
||
var target_race := -1
|
||
if client:
|
||
var target_entity: Dictionary = client.get_entity(victim_vid)
|
||
if not target_entity.is_empty():
|
||
target_race = int(target_entity.get("race", -1))
|
||
var applied := false
|
||
if n.has_method("set_motion_id"):
|
||
applied = bool(n.call("set_motion_id", motion, target_race))
|
||
if not applied:
|
||
var state := "attack"
|
||
if motion >= 305:
|
||
state = "emotion"
|
||
elif motion >= 1 and motion <= 3:
|
||
state = "wait" if motion == 1 else ("walk" if motion == 2 else "run")
|
||
elif motion == 5 or motion == 6 or motion == 8 or motion == 9:
|
||
state = "damage"
|
||
elif motion == 11 or motion == 12:
|
||
state = "dead"
|
||
if n.has_method("set_anim_state"):
|
||
n.call("set_anim_state", state)
|
||
else:
|
||
n.set_meta("motion_state", state)
|
||
|
||
# GC_PVP: 显示当前与任一角色的挑战 / 战斗 / 复仇关系。原客户端同时会影响
|
||
# TargetBoard 和名字颜色;这里先提供始终可见的世界标签,避免关系状态无表现。
|
||
func _on_pvp_changed(src_vid: int, dst_vid: int, mode: int) -> void:
|
||
if src_vid == 0 or dst_vid == 0:
|
||
return
|
||
var key := _pvp_key(src_vid, dst_vid)
|
||
if mode == 0:
|
||
_pvp_relations.erase(key)
|
||
else:
|
||
_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)]
|
||
|
||
func _pvp_mode_for(vid: int) -> int:
|
||
for relation in _pvp_relations.values():
|
||
if int(relation.get("src_vid", 0)) == vid or int(relation.get("dst_vid", 0)) == vid:
|
||
return int(relation.get("mode", 0))
|
||
return 0
|
||
|
||
func _refresh_pvp_tag(vid: int) -> void:
|
||
var n: Node3D = _by_vid.get(vid, null)
|
||
if n == null:
|
||
return
|
||
var mode := _pvp_mode_for(vid)
|
||
var tag := n.get_node_or_null("PvpTag") as Label3D
|
||
var in_duel := _duel_opponents.has(vid)
|
||
if mode == 0 and not in_duel:
|
||
if tag:
|
||
tag.queue_free()
|
||
return
|
||
if tag == null:
|
||
tag = Label3D.new()
|
||
tag.name = "PvpTag"
|
||
tag.position.y = 2.7
|
||
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||
tag.no_depth_test = true
|
||
tag.pixel_size = 0.0045
|
||
n.add_child(tag)
|
||
if in_duel:
|
||
tag.text = "决斗"
|
||
tag.modulate = Color(0.45, 0.85, 1.0)
|
||
return
|
||
match mode:
|
||
1:
|
||
tag.text = "挑战"
|
||
tag.modulate = Color(1.0, 0.85, 0.25)
|
||
2:
|
||
tag.text = "PVP"
|
||
tag.modulate = Color(1.0, 0.25, 0.25)
|
||
3:
|
||
tag.text = "复仇"
|
||
tag.modulate = Color(1.0, 0.45, 0.1)
|
||
_:
|
||
tag.text = "PVP"
|
||
tag.modulate = Color.WHITE
|
||
|
||
func _on_duel_changed(duel: Dictionary) -> void:
|
||
_duel_opponents.clear()
|
||
_duel_cannot_attack = bool(duel.get("cannot_attack", false))
|
||
for vid in duel.get("opponents", []):
|
||
var opponent := int(vid)
|
||
if opponent != 0:
|
||
_duel_opponents[opponent] = true
|
||
for vid in _by_vid.keys():
|
||
_refresh_pvp_tag(int(vid))
|
||
_apply_name_color(int(vid))
|
||
|
||
# 头顶聊天 / 信息尾标(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
|
||
_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 = BUBBLE_TAG_Y
|
||
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)
|
||
# UpdateShowingTextTail :110-114:有「聊天」气泡(bNameFlag)时名字尾标顶到气泡上方 17px。
|
||
_reposition_name_tag(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)
|
||
# 气泡没了 -> 名字尾标从「贴气泡上方」回默认局部 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)
|
||
var e: Dictionary = client.get_entity(vid)
|
||
if e.is_empty():
|
||
return
|
||
var hp := int(e.get("hp", 0))
|
||
var max_hp := int(e.get("max_hp", 0))
|
||
var dead := bool(e.get("dead", false))
|
||
vitals.emit(vid, hp, max_hp, dead)
|
||
_sync_hp_bar_values(vid, hp, max_hp, dead)
|
||
|
||
func _on_dead(vid: int) -> void:
|
||
var n: Node3D = _by_vid.get(vid, null)
|
||
if n == null:
|
||
return
|
||
# CActorInstance::Die 只 InterceptOnceMotion(NAME_DEAD),不倾倒模型;
|
||
# 只有没有动作的占位胶囊才用倒地作为唯一的死亡表现。
|
||
if n.has_method("set_anim_state"):
|
||
n.set_meta("dead", true)
|
||
n.call("set_anim_state", "dead")
|
||
else:
|
||
var t := create_tween()
|
||
t.tween_property(n, "rotation:x", deg_to_rad(-80.0), 0.3)
|
||
|
||
# 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 = {}
|
||
var queued: Variant = shooter.get_meta("fly_target_queue", [])
|
||
if queued is Array and not queued.is_empty():
|
||
target = queued.pop_front()
|
||
shooter.set_meta("fly_target_queue", queued)
|
||
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
|
||
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
|
||
# entity exists (target VID 0 carries an explicit coordinate).
|
||
func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool) -> void:
|
||
var shooter: Node3D = _by_vid.get(shooter_vid, null)
|
||
if shooter:
|
||
var queued: Array = []
|
||
var previous: Variant = shooter.get_meta("fly_target_queue", [])
|
||
if append and previous is Array:
|
||
queued = previous
|
||
queued.append({"vid": target_vid, "cm": target_cm})
|
||
shooter.set_meta("fly_target_queue", queued)
|
||
shooter.set_meta("fly_target_vid", target_vid)
|
||
shooter.set_meta("fly_target_cm", target_cm)
|
||
shooter.set_meta("fly_target_append", append)
|
||
fly_targeting.emit(shooter_vid, target_vid, target_cm, append)
|
||
|
||
func _server_cm_to_world(cm: Vector2) -> Vector3:
|
||
var p := MapCoord.to_world(Vector3(cm.x * 0.01, 0.0, -cm.y * 0.01))
|
||
if world and world.has_method("sample_height"):
|
||
p.y = float(world.call("sample_height", p.x, p.z)) + 0.6
|
||
else:
|
||
p.y = 0.6
|
||
return p
|
||
|
||
# 实体世界坐标(Godot 米);找不到返回 Vector3.INF
|
||
func _entity_pos(vid: int) -> Vector3:
|
||
var e: Dictionary = client.get_entity(vid) if client else {}
|
||
if not e.is_empty() and e.get("pos", null) is Vector3:
|
||
return MapCoord.to_world(e["pos"])
|
||
var n = _by_vid.get(vid, null)
|
||
if is_instance_valid(n):
|
||
return (n as Node3D).global_position
|
||
return Vector3.INF
|
||
|
||
func _on_dig_motion(vid: int, target_vid: int, count: int) -> void:
|
||
var n: Node3D = _by_vid.get(vid, null)
|
||
if n == null:
|
||
return
|
||
n.set_meta("dig_count", count)
|
||
n.set_meta("dig_target_vid", target_vid)
|
||
var target: Node3D = _by_vid.get(target_vid, null)
|
||
if target and target != n:
|
||
var flat := target.global_position
|
||
flat.y = n.global_position.y
|
||
if n.global_position.distance_squared_to(flat) > 0.001:
|
||
n.look_at(flat, Vector3.UP)
|
||
if n.has_method("set_anim_state"):
|
||
n.call("set_anim_state", "dig")
|
||
|
||
# GC_FISHING: `info` is the fisher VID for motion subheaders. Keep the
|
||
# authoritative direction and state on the entity; real PlayerView/MobView
|
||
# assets may provide matching fishing animations, while placeholders still
|
||
# expose the event to gameplay/UI code.
|
||
func _on_fishing_event(subheader: int, info: int, dir: int) -> void:
|
||
# FISH carries an item vnum in `info`, not a fisher VID; leave it to the
|
||
# fishing UI / inventory event consumer.
|
||
if info == 0 or subheader == 5:
|
||
return
|
||
var n: Node3D = _by_vid.get(info, null)
|
||
if n == null:
|
||
return
|
||
n.set_meta("fishing_subheader", subheader)
|
||
n.set_meta("fishing_dir", dir)
|
||
var state := "fishing"
|
||
match subheader:
|
||
1: state = "wait" # STOP
|
||
2: state = "fishing_react" # REACT
|
||
3: state = "fishing_catch" # SUCCESS
|
||
4: state = "fishing_fail" # FAIL
|
||
if n.has_method("set_anim_state"):
|
||
n.call("set_anim_state", state)
|
||
fishing_event.emit(info, subheader, dir)
|
||
|
||
# GC_DAMAGE_INFO -> RecvDamageInfoPacket -> CInstanceBase::AddDamageEffect。
|
||
# damage_number 信号是本移植扩展(战斗日志 / 测试消费),始终发;
|
||
# 飘字本体照 AddDamageEffect:仅 IsShowDamage() 时入 m_DamageQueue,ProcessDamage 每帧 pop 一条。
|
||
func _on_damage(vid: int, amount: int, flag: int) -> void:
|
||
var crit := (flag & DamageEffect.DAMAGE_CRITICAL) != 0
|
||
var dodge := (flag & DamageEffect.DAMAGE_DODGE) != 0
|
||
damage_number.emit(vid, amount, crit, dodge)
|
||
if not DamageEffect.valid_amount(amount):
|
||
return # RecvDamageInfoPacket:damage < 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 Label3D(seam ⑪:无 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 # ProcessDamage:nontarget_ 后直接 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 = 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(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)
|
||
tw.tween_property(lbl, "modulate:a", 0.0, 0.8).set_delay(0.3)
|
||
tw.chain().tween_callback(lbl.queue_free)
|
||
|
||
func _process(dt: float) -> void:
|
||
if client == null or parent == null:
|
||
return
|
||
_update_visibility()
|
||
_process_damage_queue() # §8.8:m_DamageQueue 每帧 pop 一条(CInstanceBase::ProcessDamage)
|
||
# §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):
|
||
_by_vid.erase(vid)
|
||
continue
|
||
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))
|
||
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))
|
||
# 动画状态
|
||
var f := int(e.get("func", FUNC_WAIT))
|
||
var moving := bool(e.get("moving", false))
|
||
var walk_mode := int(e.get("walk_mode", 1))
|
||
var dead := bool(e.get("dead", false))
|
||
if f != int(n.get_meta("func", -1)) or moving != bool(n.get_meta("moving", false)) \
|
||
or walk_mode != int(n.get_meta("walk_mode", -1)) or dead != bool(n.get_meta("dead", false)):
|
||
n.set_meta("func", f)
|
||
n.set_meta("moving", moving)
|
||
n.set_meta("walk_mode", walk_mode)
|
||
n.set_meta("dead", dead)
|
||
_apply_anim(n, f, moving, walk_mode, dead)
|
||
_update_hp_bars()
|
||
|
||
func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1, dead := false) -> void:
|
||
var state := "wait"
|
||
if dead:
|
||
# CActorInstance::__SetMotion:IsDead() 时拒绝 DEAD/DAMAGE_FLYING 以外的动作。
|
||
state = "dead"
|
||
elif f == FUNC_MOVE or (f == FUNC_WAIT and moving):
|
||
state = "walk" if walk_mode == 0 else "run"
|
||
elif f == FUNC_ATTACK or f == FUNC_COMBO:
|
||
state = "attack"
|
||
elif f == FUNC_MOB_SKILL or (f & FUNC_SKILL) != 0:
|
||
state = "skill"
|
||
elif f == FUNC_EMOTION:
|
||
state = "emotion"
|
||
if n.has_method("set_anim_state"):
|
||
n.call("set_anim_state", state)
|
||
# 占位胶囊(无 set_anim_state)不再借头顶名字 Label3D 的 modulate 表现动作态——
|
||
# 该 modulate 现在专属 §8.8 名字色(GetNameColor)。
|
||
|
||
func _grounded(p: Variant) -> Vector3:
|
||
# p 是 M2Client 的网络帧 pos —— 先转到 Metin2World 本地帧,再贴地。
|
||
var v: Vector3 = MapCoord.to_world(p) if p is Vector3 else Vector3.ZERO
|
||
if world and world.has_method("sample_height"):
|
||
v.y = float(world.call("sample_height", v.x, v.z))
|
||
return v
|
||
|
||
func _make_placeholder(d: Dictionary) -> Node3D:
|
||
var root := Node3D.new()
|
||
var mesh := MeshInstance3D.new()
|
||
var cap := CapsuleMesh.new()
|
||
cap.radius = 0.35
|
||
cap.height = 1.8
|
||
mesh.mesh = cap
|
||
mesh.position.y = 0.9
|
||
var mat := StandardMaterial3D.new()
|
||
var is_main := bool(d.get("is_main", false))
|
||
mat.albedo_color = Color(0.35, 0.6, 1.0) if is_main else Color(0.85, 0.4, 0.35)
|
||
mesh.material_override = mat
|
||
root.add_child(mesh)
|
||
_attach_nameplate(root, d)
|
||
return root
|
||
|
||
# 头顶名字 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 = NAME_TAG_DEFAULT_Y
|
||
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", 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()
|
||
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
|
||
|
||
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
|