feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复

- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
This commit is contained in:
shen
2026-09-19 08:51:25 -07:00
parent 1db9e9a129
commit 66d217b313
650 changed files with 53046 additions and 1586 deletions
+339 -62
View File
@@ -28,6 +28,7 @@ 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")
const UiAssets = preload("res://ui/ui_assets.gd")
signal entity_added(node: Node3D, vid: int)
signal entity_removed(vid: int)
@@ -102,10 +103,17 @@ 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 skill_table: RefCounted
var fx: RefCounted
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 _upgrade_queue: Array[int] = [] # Throttled queue for replacing placeholder capsules with real models
const MAX_UPGRADES_PER_FRAME := 4
const MAX_UPGRADE_TIME_MSEC := 4
const VISIBILITY_CHECK_INTERVAL := 0.1
var _vis_accum := 0.0
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 ⑧)
@@ -212,7 +220,7 @@ func catch_up() -> void:
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)
_on_spawn(e, true)
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)),
@@ -236,9 +244,10 @@ func _refresh_existing_model_views() -> void:
var id := int(vid)
if id == _local_vid:
continue
var d: Dictionary = client.get_entity(id)
if not d.is_empty():
_on_spawn(d)
var n: Node3D = _by_vid.get(id, null)
# Only enqueue if it's currently a placeholder or needing model rebuild
if is_instance_valid(n) and not _upgrade_queue.has(id):
_upgrade_queue.append(id)
# 本地玩家用 player_controller 的节点代表,这里就不要再画一个。
func set_local_vid(vid: int) -> void:
@@ -377,27 +386,22 @@ func main_node() -> Node3D:
# ---------------------------------------------------------------------------
func _on_spawn(d: Dictionary) -> void:
func _on_spawn(d: Dictionary, allow_defer: bool = false) -> void:
var vid := int(d.get("vid", 0))
if vid == 0 or vid == _local_vid:
if vid == 0 or vid == _local_vid or vid == _main_vid or bool(d.get("is_main", false)):
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).
# destroys and recreates the node (the data row was whole-replaced).
var keep_pos: Variant = null
var keep_rot: 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
keep_pos = existing.global_position
keep_rot = existing.rotation.y
_by_vid.erase(vid)
existing.queue_free()
elif not _is_visible_entity(d):
@@ -405,9 +409,15 @@ func _on_spawn(d: Dictionary) -> void:
# once the entity is in view. _update_visibility() creates it later when
# the main character moves into CHAR_STAGE_VIEW_BOUND.
return
var is_main: bool = (vid == _main_vid or bool(d.get("is_main", false)))
var node: Node3D = null
if _model_factory.is_valid():
if allow_defer and not is_main and _model_factory.is_valid():
node = _make_placeholder(d)
if not _upgrade_queue.has(vid):
_upgrade_queue.append(vid)
elif _model_factory.is_valid():
node = _model_factory.call(d)
_upgrade_queue.erase(vid)
if node == null:
node = _make_placeholder(d)
elif not node.has_node("Label3D"):
@@ -416,13 +426,24 @@ func _on_spawn(d: Dictionary) -> void:
parent.add_child(node)
if keep_pos is Vector3:
node.global_position = keep_pos
if keep_rot is float:
node.rotation.y = keep_rot
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")
if kind_resolver.is_valid():
ch_type = int(kind_resolver.call(d))
var shape := "ATTACK"
var explicit_kind := int(d.get("kind", -1))
if explicit_kind == EntityRules.KIND_WOODEN_DOOR or EntityRules._is_wooden_door(d) \
or bool(d.get("building", false)) or explicit_kind == EntityRules.KIND_BUILDING:
shape = "DOOR"
elif ch_type in [EntityRules.KIND_NPC, EntityRules.KIND_WARP]:
shape = "TALK"
node.set_meta("cursor_shape", shape)
_by_vid[vid] = node
_sync_hp_bar(vid, d)
_set_name_visibility(node)
@@ -487,7 +508,7 @@ func _apply_field_updates(n: Node3D, d: Dictionary) -> void:
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)))
n.call("set_affect_flags", int(d.get("affect_flags", 0)), fx)
elif n.has_method("set_affects"):
n.call("set_affects", int(d.get("affect_flags", 0)))
if n.has_method("set_move_speed"):
@@ -538,7 +559,7 @@ func _update_visibility() -> void:
dist = _planar_cm(e).distance_to(main_cm)
if forced or dist < CHAR_STAGE_VIEW_BOUND:
if not has_node:
_on_spawn(e)
_on_spawn(e, true)
elif has_node and dist > cull:
_fade_and_free(vid)
@@ -571,7 +592,15 @@ func _show_always(e: Dictionary) -> bool:
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
func _detach_text_tail(n: Node3D) -> void:
if not is_instance_valid(n):
return
for tag_name in ["NameTag", "GuildTag", "TitleTag", "LevelTag", "GuildMark", "Shadow", "ShopSign"]:
var node := n.get_node_or_null(tag_name)
if node != null:
node.visible = false
# §2.1 cull / 40250 DeleteBlendOut: 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)
@@ -581,8 +610,11 @@ func _fade_and_free(vid: int) -> void:
return
_fading[vid] = true
entity_removed.emit(vid)
_remove_hp_bar(vid)
_detach_text_tail(n)
var tw := create_tween()
tw.tween_property(n, "scale", Vector3.ONE * 0.01, 0.3)
tw.tween_property(n, "scale", Vector3.ONE * 0.01, 0.4)
tw.parallel().tween_property(n, "position:y", n.position.y - 0.5, 0.4)
tw.tween_callback(func() -> void:
if is_instance_valid(n):
n.queue_free()
@@ -856,16 +888,17 @@ func set_pk_title_enabled(enabled: bool) -> void:
_retint_all_text_tails()
func _on_despawn(vid: int) -> void:
_upgrade_queue.erase(vid)
_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)
if _target_vid == vid:
_target_vid = 0
if _hover_vid == vid:
_hover_vid = 0
if _by_vid.has(vid):
_fade_and_free(vid)
func _on_main_set(vid: int) -> void:
_main_vid = vid
@@ -1091,20 +1124,40 @@ func _on_vitals(vid: int) -> void:
var dead := bool(e.get("dead", false))
vitals.emit(vid, hp, max_hp, dead)
_sync_hp_bar_values(vid, hp, max_hp, dead)
if dead or (max_hp > 0 and hp <= 0):
if n and is_instance_valid(n) and not bool(n.get_meta("dead", false)):
_on_dead(vid)
func _on_dead(vid: int) -> void:
var n: Node3D = _by_vid.get(vid, null)
if n == null:
return
# 40250 CInstanceBase::Die() -> DetachTextTail(), OnUnselected(), OnUntargeted()
_remove_hp_bar(vid)
_detach_text_tail(n)
n.set_meta("dead", true)
if _target_vid == vid:
_target_vid = 0
if _hover_vid == vid:
_hover_vid = 0
# 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)
# 40250 UpdateDeleting / DeleteInstanceByFade:
# 尸体倒地停留 2.5 秒后平滑渐隐沉地释放;若服务端提前到达 GC_CHARACTER_DEL 则在 _on_despawn 中直接 fade_and_free。
var tw := create_tween()
tw.tween_interval(2.5)
tw.tween_callback(func() -> void:
if is_instance_valid(n) and _by_vid.get(vid) == n:
_fade_and_free(vid))
# P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体 / 坐标。原客户端在
# GC_CREATE_FLY 之前可能只收到 FLY_TARGETINGtarget VID=0 时是坐标),所以这里也消费
# shooter 的目标队列,避免远端技能没有终点被静默丢弃。§3.6:交给 FlyManager 跑真实弹道
@@ -1138,15 +1191,66 @@ func _on_fly(_type: int, start_vid: int, end_vid: int) -> void:
_fly_mgr.world = world
var start_w := a + Vector3(0, 1.0, 0)
var goal_w := b + Vector3(0, 1.0, 0)
var tgt: Variant = target_node if is_instance_valid(target_node) else goal_w
if _type == FlyObject.FLY_EXP:
# 40250 官方经验聚能光球: ga_piece_yellow_small2.msf
# 自导向追踪玩家胸膛 (is_homing=true, homing_start_time=0.05, homing_max_angle=140.0)
for k in range(3):
var offset = Vector3(randf_range(-0.25, 0.25), randf_range(0.0, 0.3), randf_range(-0.25, 0.25))
var orb_d = FlyObject.FlyData.new()
orb_d.init_vel = 11.0 + k * 2.0
orb_d.flat_range = maxf(start_w.distance_to(goal_w) * 2.5, 40.0)
orb_d.bomb_range = 0.8
orb_d.is_homing = true
orb_d.homing_start_time = 0.05 + k * 0.04
orb_d.homing_max_angle = 140.0
orb_d.maintain_parallel = true
orb_d.pierce_count = 0
_fly_mgr.spawn(start_w + offset, tgt, false, orb_d, FlyObject.FLY_EXP)
return
# 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)
# 40250 供单机/离线击杀或事件直接调用发射经验光球
func spawn_exp_fly(from_pos: Vector3, to_node: Node3D = null, count: int = 3) -> void:
if _fly_mgr == null or parent == null:
return
_fly_mgr.parent = parent
_fly_mgr.world = world
var start_w := from_pos + Vector3(0, 0.8, 0)
var tgt_node: Node3D = to_node
if tgt_node == null:
var main_v: int = client.get_main_vid() if client and client.has_method("get_main_vid") else 1
tgt_node = _by_vid.get(main_v, null)
var tgt_pos := (tgt_node.global_position if tgt_node.is_inside_tree() else tgt_node.position) if is_instance_valid(tgt_node) else start_w + Vector3(0, 1, 2)
var goal_w := tgt_pos + Vector3(0, 1.0, 0)
var tgt: Variant = tgt_node if is_instance_valid(tgt_node) else goal_w
for k in range(count):
var offset = Vector3(randf_range(-0.25, 0.25), randf_range(0.0, 0.3), randf_range(-0.25, 0.25))
var orb_d = FlyObject.FlyData.new()
orb_d.init_vel = 11.0 + k * 2.0
orb_d.flat_range = maxf(start_w.distance_to(goal_w) * 2.5, 40.0)
orb_d.bomb_range = 0.8
orb_d.is_homing = true
orb_d.homing_start_time = 0.05 + k * 0.04
orb_d.homing_max_angle = 140.0
orb_d.maintain_parallel = true
orb_d.pierce_count = 0
_fly_mgr.spawn(start_w + offset, tgt, false, orb_d, FlyObject.FLY_EXP)
func get_entity_node(vid: int) -> Node3D:
return _by_vid.get(vid, null)
# 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).
@@ -1213,14 +1317,56 @@ func _on_fishing_event(subheader: int, info: int, dir: int) -> void:
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
1:
_clear_fish_emoticon(n)
state = "wait" # STOP
2:
_show_fish_emoticon(n)
state = "fishing_react" # REACT
3:
_clear_fish_emoticon(n)
state = "fishing_catch" # SUCCESS
4:
_clear_fish_emoticon(n)
state = "fishing_fail" # FAIL
if n.has_method("set_anim_state"):
n.call("set_anim_state", state)
fishing_event.emit(info, subheader, dir)
func _show_fish_emoticon(n: Node3D) -> void:
if n == null or not is_instance_valid(n):
return
var old_emo := n.get_node_or_null("FishEmoticon")
if old_emo != null:
old_emo.queue_free()
var assets_path := AssetRoot.path() if AssetRoot.available() else ""
var tex: Texture2D = null
if assets_path != "":
tex = UiAssets.load_tex(assets_path, "Effect/ymir work/effect/etc/emoticon/fish.dds")
var emo := Sprite3D.new()
emo.name = "FishEmoticon"
emo.billboard = BaseMaterial3D.BILLBOARD_ENABLED
emo.no_depth_test = true
emo.pixel_size = 0.008
emo.position = Vector3(0, 2.4, 0)
if tex != null:
emo.texture = tex
n.add_child(emo)
emo.scale = Vector3(0.3, 0.3, 0.3)
var tw := emo.create_tween()
tw.tween_property(emo, "scale", Vector3(1.2, 1.2, 1.2), 0.15)
tw.tween_property(emo, "scale", Vector3(1.0, 1.0, 1.0), 0.1)
tw.tween_interval(2.5)
tw.tween_property(emo, "modulate:a", 0.0, 0.3)
tw.tween_callback(emo.queue_free)
func _clear_fish_emoticon(n: Node3D) -> void:
if n == null or not is_instance_valid(n):
return
var emo := n.get_node_or_null("FishEmoticon")
if emo != null:
emo.queue_free()
# GC_DAMAGE_INFO -> RecvDamageInfoPacket -> CInstanceBase::AddDamageEffect。
# damage_number 信号是本移植扩展(战斗日志 / 测试消费),始终发;
# 飘字本体照 AddDamageEffect:仅 IsShowDamage() 时入 m_DamageQueueProcessDamage 每帧 pop 一条。
@@ -1228,6 +1374,8 @@ 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 crit and _camera != null and _camera.has_method("shake"):
_camera.shake(0.05, 9.0)
if not DamageEffect.valid_amount(amount):
return # RecvDamageInfoPacketdamage < 0 -> TraceError,不处理
if not DamageEffect.should_queue(_show_damage):
@@ -1238,8 +1386,12 @@ func _on_damage(vid: int, amount: int, flag: int) -> void:
"target": DamageEffect.is_target(vid, _target_vid),
})
# ProcessDamage:每帧 pop_front 一条。DODGE|BLOCK -> "MISS"nontarget 数字 -> 不渲染;
# 其余把 amount 铺一块 billboard Label3Dseam ⑪:无 damagevalue dds 图集 / 逐位特效)。
# ProcessDamage:每帧 pop_front 一条。
# 40250 官方 1:1InstanceBaseEffect.cpp:150-205):
# DODGE|BLOCK -> MISS(自身 damage_miss.dds,目标 target_miss.dds);
# nontarget 数字 -> 不渲染;
# self / target 伤害数字按 FONT_WIDTH=30px 水平排布真实 DDS 纹理(damagevalue/),
# 纹理缺失时安全降级为 Label3D。
func _process_damage_queue() -> void:
if _damage_queue.is_empty():
return
@@ -1250,35 +1402,129 @@ func _process_damage_queue() -> void:
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 ⑪:退化成落字水平抖动符号交替)。
# self 数字:SELFDAMAGE / SELFDAMAGE2 交替(水平抖动交替)
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 root_node := Node3D.new()
root_node.name = "dmg_%d" % int(d["vid"])
parent.add_child(root_node)
root_node.global_position = n.global_position + Vector3(jitter, 1.9, 0)
var assets_path := AssetRoot.path() if AssetRoot.available() else ""
var built_sprites := false
if assets_path != "":
if info["kind"] == DamageEffect.Kind.MISS:
var miss_file := "damage_miss.dds" if bool(d["self"]) else "target_miss.dds"
var tex: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/" + miss_file)
if tex != null:
var sp := Sprite3D.new()
sp.texture = tex
sp.billboard = BaseMaterial3D.BILLBOARD_ENABLED
sp.no_depth_test = true
sp.pixel_size = 0.007
root_node.add_child(sp)
built_sprites = true
elif info["kind"] == DamageEffect.Kind.DIGITS:
var digit_arr: Array = DamageEffect.digits(int(d["amount"]))
if digit_arr.is_empty():
root_node.queue_free()
return
var prefix: String = String(info.get("type", "target_"))
var total_digits: int = digit_arr.size()
var sprites: Array[Sprite3D] = []
var all_loaded := true
for i in total_digits:
var dig: int = int(digit_arr[i])
var tex: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/%s%d.dds" % [prefix, dig])
if tex == null:
all_loaded = false
break
var sp := Sprite3D.new()
sp.texture = tex
sp.billboard = BaseMaterial3D.BILLBOARD_ENABLED
sp.no_depth_test = true
sp.pixel_size = 0.007
# 40250 matTrans._41 = -(FONT_WIDTH * index)(低位在右,高位在左)+ 水平居中
var px_offset := -float(DamageEffect.FONT_WIDTH * i) + float(DamageEffect.FONT_WIDTH * (total_digits - 1)) * 0.5
sp.position.x = px_offset * 0.007
sprites.append(sp)
if all_loaded and not sprites.is_empty():
for sp in sprites:
root_node.add_child(sp)
built_sprites = true
else:
for sp in sprites:
sp.queue_free()
if not built_sprites:
var text := "MISS"
if info["kind"] == DamageEffect.Kind.DIGITS:
if DamageEffect.digits(int(d["amount"])).is_empty():
root_node.queue_free()
return
text = str(int(d["amount"]))
var lbl := Label3D.new()
lbl.text = text
if int(d["flag"]) & DamageEffect.DAMAGE_CRITICAL != 0:
lbl.modulate = Color(1.0, 0.85, 0.2)
lbl.font_size = 46
lbl.outline_size = 8
lbl.outline_modulate = Color(0.3, 0.1, 0.0)
else:
lbl.modulate = Color(1, 1, 1)
lbl.font_size = 32
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
lbl.no_depth_test = true
lbl.pixel_size = 0.007
root_node.add_child(lbl)
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)
tw.tween_property(root_node, "global_position:y", root_node.global_position.y + 1.2, 0.8)
for ch in root_node.get_children():
if ch is Sprite3D or ch is Label3D:
tw.tween_property(ch, "modulate:a", 0.0, 0.8).set_delay(0.3)
tw.chain().tween_callback(root_node.queue_free)
func _process_upgrade_queue() -> void:
if _upgrade_queue.is_empty() or not _model_factory.is_valid() or client == null or not client.has_method("get_entity"):
return
var m := main_node()
if is_instance_valid(m) and _upgrade_queue.size() > 1:
var mpos := m.global_position
_upgrade_queue.sort_custom(func(a: int, b: int) -> bool:
var na: Node3D = _by_vid.get(a, null)
var nb: Node3D = _by_vid.get(b, null)
var da := na.global_position.distance_squared_to(mpos) if is_instance_valid(na) else INF
var db := nb.global_position.distance_squared_to(mpos) if is_instance_valid(nb) else INF
return da < db
)
var start_ms := Time.get_ticks_msec()
var count := 0
while not _upgrade_queue.is_empty() and count < MAX_UPGRADES_PER_FRAME:
var vid: int = _upgrade_queue.pop_front()
if vid == _local_vid:
continue
var d: Dictionary = client.get_entity(vid)
if not d.is_empty() and _by_vid.has(vid):
_on_spawn(d, false)
count += 1
if (Time.get_ticks_msec() - start_ms) >= MAX_UPGRADE_TIME_MSEC:
break
func _process(dt: float) -> void:
if client == null or parent == null:
return
_update_visibility()
_process_upgrade_queue()
_vis_accum += dt
if _vis_accum >= VISIBILITY_CHECK_INTERVAL:
_vis_accum = 0.0
_update_visibility()
_process_damage_queue() # §8.8m_DamageQueue 每帧 pop 一条(CInstanceBase::ProcessDamage
# §8.8:名字色随队伍 / 帝国名色模式 / PVP·决斗边沿变化——用 ~2Hz 节流整表刷新
# 兜底(单发路径已覆盖 spawn/info/pvp/duel/main_set;参考端每帧都算 GetNameColor)。
@@ -1299,6 +1545,11 @@ func _process(dt: float) -> void:
var e: Dictionary = client.get_entity(vid)
if e.is_empty():
continue
var is_dead := bool(e.get("dead", false)) or bool(n.get_meta("dead", false))
if is_dead:
if not bool(n.get_meta("dead", false)):
_on_dead(vid)
continue
# §3.2:位置插值现在完全在 C++ EntityStore::tick() 里做(命令队列 +
# SNetworkActorData::UpdatePosition 式线性插值),这里只跟随 e.pos,不再
# 叠第二层 follow_lerp——否则会把 C++ 的到达时机拖慢、和动作事件脱节。
@@ -1307,10 +1558,13 @@ func _process(dt: float) -> void:
want = _pushed_position(vid, e, dt, want)
n.position = want
# 朝向:Metin2 heading(度) -> Godot yaw。经验换算,真机再校准。
# CActorInstance::RotationProcess:匀角速度转向 c_fDefaultRotationSpeed 1200°/s
# 骑马 c_fDefaultHorseRotationSpeed 300°/s(而不是指数逼近)。
# CActorInstance::RotationProcess / SetAdvancingRotation
# 差值 > 45° 时全速转向(步战 1200°/s,骑乘 300°/s);差值 <= 45° 时按 5/12 阻尼减速(500°/s 或 125°/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
var diff: float = wrapf(yaw - n.rotation.y, -PI, PI)
if absf(diff) <= deg_to_rad(45.0):
turn_rate *= 5.0 / 12.0
n.rotation.y = rotate_toward(n.rotation.y, yaw, deg_to_rad(turn_rate) * dt)
# 动画状态
var f := int(e.get("func", FUNC_WAIT))
@@ -1336,6 +1590,13 @@ func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := WALKMODE_RUN, dea
elif f == FUNC_ATTACK or f == FUNC_COMBO:
state = "attack"
elif f == FUNC_MOB_SKILL or (f & FUNC_SKILL) != 0:
if (f & FUNC_SKILL) != 0:
var mot_idx := f & 0x7f
if mot_idx > 0 and skill_table and n.has_method("play_skill_motion"):
var mname: String = skill_table.motion_name_by_idx(mot_idx)
if mname != "":
n.call("play_skill_motion", mname)
return
state = "skill"
elif f == FUNC_EMOTION:
state = "emotion"
@@ -1367,7 +1628,15 @@ func push_victim(vid: int, dir: Vector2, force: float) -> void:
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)
var collide_fn := Callable()
var base_world := MapCoord.to_world(e.get("pos", Vector3.ZERO))
# 40250 CPhysicsObject::IncreaseExternalForce: pWorld->isPhysicalCollision
# CMapManager::isPhysicalCollision 仅判定地形 ATTRIBUTE_BLOCKworld.is_blocked
collide_fn = func(movement_cm: Vector2) -> bool:
var cand_x := base_world.x + movement_cm.x / 100.0
var cand_z := base_world.z - movement_cm.y / 100.0
return world != null and world.has_method("is_blocked") and bool(world.call("is_blocked", cand_x, cand_z))
pe.obj.increase_external_force(force, collide_fn)
# CActorInstance::IsPushing -> m_PhysicsObject.isBlending()
func is_pushing(vid: int) -> bool:
@@ -1384,7 +1653,7 @@ func blending_position(vid: int) -> Vector3:
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
# 位移叠在服务器位置上;遇地形阻挡则触发 BlockMovement 停在边界;blend 结束后服务器位置一变,以服务器为准丢弃本地偏移。
func _pushed_position(vid: int, e: Dictionary, dt: float, want: Vector3) -> Vector3:
var pe: Dictionary = _push[vid]
var obj = pe.obj
@@ -1398,7 +1667,14 @@ func _pushed_position(vid: int, e: Dictionary, dt: float, want: Vector3) -> Vect
pe.base = pos
if obj.is_blending():
obj.update(dt)
pe.off += obj.get_movement()
var d_move: Vector2 = obj.get_movement()
var cand_off: Vector2 = pe.off + d_move
var cand_world := MapCoord.to_world(pos) + Vector3(cand_off.x / 100.0, 0.0, -cand_off.y / 100.0)
if world != null and world.has_method("is_blocked") and bool(world.call("is_blocked", cand_world.x, cand_world.z)):
# BlockMovement:撞墙停止位移
obj.initialize()
else:
pe.off = cand_off
var off: Vector2 = pe.off
return _ground_world(MapCoord.to_world(pos) + Vector3(off.x / 100.0, 0.0, -off.y / 100.0))
@@ -1488,6 +1764,8 @@ 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
bar.set_meta("max_hp", max_hp)
bar.set_meta("dead", dead)
if bar.has_method("set_vitals"):
bar.call("set_vitals", hp, max_hp, dead)
bar.visible = max_hp > 0 and not dead
@@ -1526,9 +1804,8 @@ func _update_hp_bars() -> void:
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))
var max_hp: int = int(bar.get_meta("max_hp", 0))
var dead: bool = bool(bar.get_meta("dead", false)) or bool(node.get_meta("dead", false))
if max_hp <= 0 or dead:
bar.visible = false
continue