Files
2026-09-16 22:15:52 +09:00

1545 lines
64 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# NetWorld —— 把 M2Client 的网络实体镜像成 Godot 场景节点。
#
# 消费 M2Client 的 entity_spawned / entity_despawned / entity_moved /
# entity_main_set / chat 信号:每个 vid 一个子节点,位置向 client.get_entity(vid).pos
# 平滑逼近,rotation.y 取自 angle_deg,动画状态取自 funcFUNC_*)。
#
# 默认生成「占位胶囊 + 名字牌」(快、无依赖,先跑通同步/插值/状态循环)。
# 要换成真模型: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")
const PhysicsPush = preload("res://physics_push.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
# packet.h EWalkModesGC_WALK_MODE / GC_CHARACTER_ADD_INFO.bWalkMode
const WALKMODE_RUN := 0
const WALKMODE_WALK := 1
# CActorInstance c_fDefaultRotationSpeed / c_fDefaultHorseRotationSpeed(度/秒)
const ROTATION_SPEED := 1200.0
const ROTATION_SPEED_HORSE := 300.0
# EDamageFlag / IsShowDamage / ProcessDamage 分类见 damage_effect.gdDamageEffect.DAMAGE_*)。
# §8.8 头顶名字 / 聊天气泡 billboard 的默认局部 y(世界单位)。seam ⑨(name_tail_layout.gd):
# 有「聊天」气泡时名字尾标顶到气泡上方 17px(换算按名字 Label3D 的 pixel_size)。
const NAME_TAG_DEFAULT_Y := 2.1
const BUBBLE_TAG_Y := 2.35
# §8.8 公会名 / 称号 / 等级子标签的默认局部 y(竖排近似:公会名在名字上、称号 / 等级在下)。
# seam ⑦(name_tail_layout.gd):参考端 ArrangeTextTail 把它们锚在名字尾标 pTextTail->y 上,
# 名字因聊天气泡上移时三者整体跟随——_reposition_name_tag 按 name_tail_lift_delta 补这段位移。
const SUBTAG_DEFAULT_Y := {"GuildTag": 2.34, "TitleTag": 1.86, "LevelTag": 1.74}
# seam ⑦ 水平排版(增量 121):ArrangeTextTail 按 GetTextSize 精确像素排称号 / 等级,
# poc 无字体度量 -> 按「字符数 * 约 7px」估文本宽(与 ground_items 的 TAG_CHAR_W_PX 同源)。
const SUBTAG_CHAR_W_PX := 7.0
# §8.8:参考端的实体 HP 条属于屏幕尾标,而不是 3D billboard。条的锚点
# 保持在原占位条高度附近;实际像素位置由 Camera3D.unproject_position() 计算。
const HP_BAR_WORLD_Y := 1.95
const HP_BAR_SCREEN_MARGIN := 32.0
# CLIENT-GAP §2.1 two-layer model. The data layer (M2Client / EntityStore) always
# holds every known entity; a scene node under `parent` exists only while the
# 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
# §3.7 受击击退:vid -> {obj: PhysicsPush(CPhysicsObject), off: Vector2 累计位移 cm, base: 推开时的 e.pos}
var _push := {}
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) -> int0 PC/1 NPC/2 怪/3 石/4 warpnet_play._entity_kind
var title_name_resolver: Callable = Callable() # §8.8 func(grade:int) -> StringPVP_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「总显示名字」radioCPythonSystem::IsAlwaysShowName
# _alt_held —— LALT 按住(game.py ShowName/HideName 的 ShowNameFlag
# 另加 forced 路径:_target_vidtargetBoard.GetTargetVID()+ chat 尾标 bNameFlag +
# _hover_vidgame.py OnRender chr.Pick() -> ShowCharacterTextTail,见 pick_show.gd)。
var _always_show_name := NameShow.ALWAYS_SHOW_NAME_DEFAULT
var _alt_held := false
var _target_vid := 0
var _hover_vid := 0 # player_controller._update_hover_cursor 射线拾到的角色 vid0 = 无)
# §8.8 / §5.4 伤害飘字(damage_effect.gd)。CPythonSystem::IsShowDamage 门(uigameoption
# 「伤害数字」radio+ CInstanceBase::m_DamageQueue(每帧 pop 一条)+ ProcessDamage 分类。
var _show_damage := DamageEffect.SHOW_DAMAGE_DEFAULT
var _damage_queue: Array = [] # m_DamageQueue{vid, amount, flag, self, target}
var _dmg_self_toggle := false # m_bDamageEffectTypeSELFDAMAGE / SELFDAMAGE2 交替)
var _fly_mgr: Node # FlyManagerfly_object.gd)—— GC_CREATE_FLY 的真实弹道 + 命中判定。§3.6
var _hp_layer: CanvasLayer # 屏幕空间尾标层(低于 HUD / UiManager
var _hp_overlay: Control
var _hp_bars := {} # vid:int -> ScreenHpBar
var _camera: Camera3D
# Fishing surface provider bridge. NetWorld owns entity presentation while
# Metin2World owns map data; keep the ClientVS22 GetFishingRot query on the
# 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::SetShowDamageFlagAddDamageEffect 的入队门)。
func set_show_damage(on: bool) -> void:
_show_damage = on
# targetBoard.GetTargetVID():目标始终强显名字(game.py OnRender ShowCharacterTextTail(targetVID))。
func set_target_vid(vid: int) -> void:
if _target_vid == vid:
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 OnRenderwndMgr.IsPickedWindow 门内 chr.Pick() 命中的角色 -> ShowCharacterTextTail
# (强显名字,绕过 __IsShowName())。poc 由 player_controller 的射线悬停拾取喂入。
# 见 pick_show.gd(第 27 个 canonical)与增量 113 的 _target_vid 同路数。
func set_hover_vid(vid: int) -> void:
if _hover_vid == vid:
return
var prev := _hover_vid
_hover_vid = vid
for v in [prev, vid]:
if v != 0 and _by_vid.has(v):
_set_name_visibility(_by_vid[v])
func _refresh_all_name_visibility() -> void:
for node in _by_vid.values():
_set_name_visibility(node)
# 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 侧组装:forcedtarget / hover / chat bNameFlag+
# __IsShowName()_always_show_name or _alt_held+ 3500 平面距 + isShow/wall/CanPick 资格。
func _name_shown(root: Node3D) -> bool:
if root == null or not is_instance_valid(root):
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 OnRendertargetBoard.GetTargetVID()(增量 113+ chr.Pick() 悬停命中
# _hover_vid,增量 116 / pick_show.gd)都走 ShowCharacterTextTail 强显。
var forced := vid != 0 and (vid == _target_vid or vid == _hover_vid)
var ct: Variant = _chat_tails.get(vid, null)
if ct is Dictionary and bool(ct.get("name_flag", false)):
forced = true
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)
_push_motion_speeds(vid, node)
_apply_name_color(vid)
_apply_text_tail(vid)
entity_added.emit(node, vid)
if vid == _main_vid:
main_entity_ready.emit(node, vid)
# CActorInstance::Move -> SetLoopMotion(WALK/RUN, .., m_fMovSpd):远端位移按动作根运动
# 累计(.msa Accumulation / MotionDuration × movSpd/100)推进。模型建好后把走 / 跑
# 速度交给 EntityStore;占位胶囊 / 骑马(马的动作不在 general 目录)不推,EntityStore
# 退回按服务端 duration 线性插值。
func _push_motion_speeds(vid: int, n: Node3D) -> void:
if not n.has_method("get_move_motion_speeds") or not client.has_method("set_entity_motion_speed"):
return
if int(n.get_meta("mount_vnum", 0)) != 0:
return
var sp: Vector2 = n.call("get_move_motion_speeds")
if sp.x > 0.0 or sp.y > 0.0:
client.call("set_entity_motion_speed", vid, sp.x, sp.y)
# 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,增量 123SUBTAG_CHAR_W_PX 仅作无字体兜底)。
# 经 name_tail_layout.gd 纯静态镜像。
func _layout_subtags_x(n: Node3D, ttag: Label3D, ltag: Label3D) -> void:
var nlbl := n.get_node_or_null("Label3D") as Label3D
var gtag := n.get_node_or_null("GuildTag") as Label3D
var mark := n.get_node_or_null("GuildMark") as Sprite3D
var has_title := ttag != null and ttag.text != ""
var has_level := ltag != null and ltag.text != ""
var name_w := 0.0
if nlbl:
name_w = maxf(1.0, TextMetrics.label_width(nlbl, SUBTAG_CHAR_W_PX))
var title_w := 0.0
if has_title:
title_w = maxf(1.0, TextMetrics.label_width(ttag, SUBTAG_CHAR_W_PX))
if nlbl:
nlbl.position.x = NameTailLayout.subtag_world_x(
NameTailLayout.name_x_shift(has_title, has_level), nlbl.pixel_size)
if ttag:
ttag.position.x = NameTailLayout.subtag_world_x(
NameTailLayout.title_x_offset(name_w), ttag.pixel_size) if has_title else 0.0
if ltag:
ltag.position.x = NameTailLayout.subtag_world_x(
NameTailLayout.level_x_offset(name_w, title_w, has_title), ltag.pixel_size) if has_level else 0.0
if gtag:
if mark != null and mark.texture != null and gtag.text != "":
var guild_w := maxf(1.0, TextMetrics.label_width(gtag, SUBTAG_CHAR_W_PX))
var mark_w := maxf(1.0, float(mark.texture.get_width()))
gtag.position.x = NameTailLayout.subtag_world_x(
NameTailLayout.guild_name_x_offset(mark_w), gtag.pixel_size)
mark.position.x = NameTailLayout.subtag_world_x(
NameTailLayout.mark_x_offset(guild_w, mark_w), mark.pixel_size)
else:
gtag.position.x = 0.0 # 无已下载会徽时保持参考端 if(pMark && pGuild) 不成立
if mark:
mark.position.x = 0.0
func _apply_guild_mark(n: Node3D, guild_id: int, guild_tag: Label3D, shown: bool) -> void:
# CPythonTextTail only creates pMarkInstance when a guild id resolves to a
# downloaded mark. Do not leave a stale image after a guild change or a
# mark-table refresh that no longer contains this guild.
var mark := n.get_node_or_null("GuildMark") as Sprite3D
if guild_id == 0 or guild_tag == null or guild_tag.text == "" \
or client == null or not client.has_method("get_guild_mark_image"):
if mark:
mark.queue_free()
return
var img: Image = client.get_guild_mark_image(guild_id)
if img == null or img.get_width() <= 0 or img.get_height() <= 0:
if mark:
mark.queue_free()
return
if mark == null:
mark = Sprite3D.new()
mark.name = "GuildMark"
mark.billboard = BaseMaterial3D.BILLBOARD_ENABLED
mark.no_depth_test = true
mark.pixel_size = 0.005
mark.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
n.add_child(mark)
mark.texture = ImageTexture.create_from_image(img)
mark.visible = shown
mark.set_meta("guild_id", guild_id)
func _refresh_guild_mark(guild_id: int) -> void:
if guild_id == 0 or client == null or not client.has_method("get_entities"):
return
for vid in _by_vid.keys():
var n: Node3D = _by_vid[vid]
if not is_instance_valid(n):
continue
var e: Dictionary = client.get_entity(int(vid))
if int(e.get("guild", 0)) == guild_id:
_apply_text_tail_to(n, e)
func _apply_text_tail(vid: int) -> void:
if vid == 0 or client == null or not client.has_method("get_entity"):
return
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::EnablePKTitlegame.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)
_push.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)
# UpdateAllTextTailchat 尾标 bNameFlag TRUE 时强制显示该角色名字(不受 3500 限制)。
_set_name_visibility(n)
# UpdateShowingTextTail :110-114:有「聊天」气泡(bNameFlag)时名字尾标顶到气泡上方 17px。
_reposition_name_tag(n)
func _expire_chat_tails() -> void:
# UpdateAllTextTailLivingTime < now 即删除对应 "bubble" 节点
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_TARGETINGtarget 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_DamageQueueProcessDamage 每帧 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 # RecvDamageInfoPacketdamage < 0 -> TraceError,不处理
if not DamageEffect.should_queue(_show_damage):
return # AddDamageEffect!IsShowDamage() -> 不入队
_damage_queue.append({
"vid": vid, "amount": amount, "flag": flag,
"self": DamageEffect.is_self(vid, _main_vid),
"target": DamageEffect.is_target(vid, _target_vid),
})
# ProcessDamage:每帧 pop_front 一条。DODGE|BLOCK -> "MISS"nontarget 数字 -> 不渲染;
# 其余把 amount 铺一块 billboard Label3Dseam ⑪:无 damagevalue dds 图集 / 逐位特效)。
func _process_damage_queue() -> void:
if _damage_queue.is_empty():
return
var d: Dictionary = _damage_queue.pop_front()
var n: Node3D = _by_vid.get(int(d["vid"]), null)
if n == null or not is_instance_valid(n) or parent == null:
return
var info := DamageEffect.classify(int(d["flag"]), bool(d["self"]), bool(d["target"]))
if not info["show"]:
return # ProcessDamagenontarget_ 后直接 return(不渲染)
var text := "MISS"
if info["kind"] == DamageEffect.Kind.DIGITS:
if DamageEffect.digits(int(d["amount"])).is_empty():
return # while(damage>0) 零次 -> 无贴图
text = str(int(d["amount"]))
# self 数字:SELFDAMAGE / SELFDAMAGE2 交替(seam ⑪:退化成落字水平抖动符号交替)。
var jitter := 0.0
if bool(d["self"]) and info["kind"] == DamageEffect.Kind.DIGITS:
jitter = 0.3 if DamageEffect.self_damage_slot(_dmg_self_toggle) == 0 else -0.3
_dmg_self_toggle = not _dmg_self_toggle
var lbl := Label3D.new()
lbl.text = 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.8m_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.6ShowAllTextTail 的 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))
if _push.has(vid):
want = _pushed_position(vid, e, dt, want)
n.position = want
# 朝向:Metin2 heading(度) -> Godot yaw。经验换算,真机再校准。
# CActorInstance::RotationProcess:匀角速度转向 c_fDefaultRotationSpeed 1200°/s
# 骑马 c_fDefaultHorseRotationSpeed 300°/s(而不是指数逼近)。
var yaw := MapCoord.heading_to_yaw(float(e.get("angle_deg", 0.0)))
var turn_rate := ROTATION_SPEED_HORSE if int(n.get_meta("mount_vnum", 0)) != 0 else ROTATION_SPEED
n.rotation.y = rotate_toward(n.rotation.y, yaw, deg_to_rad(turn_rate) * dt)
# 动画状态
var f := int(e.get("func", FUNC_WAIT))
var moving := bool(e.get("moving", false))
var walk_mode := int(e.get("walk_mode", WALKMODE_RUN))
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 := WALKMODE_RUN, dead := false) -> void:
var state := "wait"
if dead:
# CActorInstance::__SetMotionIsDead() 时拒绝 DEAD/DAMAGE_FLYING 以外的动作。
state = "dead"
elif f == FUNC_MOVE or (f == FUNC_WAIT and moving):
state = "walk" if walk_mode == WALKMODE_WALK 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
return _ground_world(v)
func _ground_world(v: Vector3) -> Vector3:
if world and world.has_method("sample_height"):
v.y = float(world.call("sample_height", v.x, v.z))
return v
# §3.7 __PushCircle + IncreaseExternalForceActorInstanceBattle.cpp / PhysicsObject.cpp):
# dir = normalize(受击方 - 攻击方)Metin2 actor 平面帧(cm)。seam:无 IPhysicsWorld 地形碰撞回调。
func push_victim(vid: int, dir: Vector2, force: float) -> void:
if client == null or force <= 0.0 or not _by_vid.has(vid):
return
var e: Dictionary = client.get_entity(vid)
if e.is_empty():
return
var pe: Dictionary = _push.get(vid, {})
if pe.is_empty():
pe = {"obj": PhysicsPush.new(), "off": Vector2.ZERO, "base": e.get("pos")}
_push[vid] = pe
pe.obj.set_direction(dir)
pe.obj.increase_external_force(force)
# CActorInstance::IsPushing -> m_PhysicsObject.isBlending()
func is_pushing(vid: int) -> bool:
return _push.has(vid) and bool(_push[vid].obj.is_blending())
# CActorInstance::GetBlendingPositionblending 中 = 当前位置 + LastPosition(击退终点),否则当前位置。
func blending_position(vid: int) -> Vector3:
var n: Node3D = _by_vid.get(vid, null)
if n == null:
return Vector3.ZERO
if not is_pushing(vid):
return n.global_position
var last: Vector2 = _push[vid].obj.get_last_position()
return n.global_position + Vector3(last.x / 100.0, 0.0, -last.y / 100.0)
# CActorInstance::PhysicsProcess:每帧 m_PhysicsObject.Update + AddMovement(GetX/YMovement)。
# 位移叠在服务器位置上;blend 结束后服务器位置一变,以服务器为准丢弃本地偏移(seam)。
func _pushed_position(vid: int, e: Dictionary, dt: float, want: Vector3) -> Vector3:
var pe: Dictionary = _push[vid]
var obj = pe.obj
var pos: Variant = e.get("pos")
if not (pos is Vector3):
return want
if pos != pe.base:
if not obj.is_blending():
_push.erase(vid)
return want
pe.base = pos
if obj.is_blending():
obj.update(dt)
pe.off += obj.get_movement()
var off: Vector2 = pe.off
return _ground_world(MapCoord.to_world(pos) + Vector3(off.x / 100.0, 0.0, -off.y / 100.0))
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