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:
+339
-62
@@ -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) -> 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 ⑧)
|
||||
@@ -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_TARGETING(target 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_DamageQueue,ProcessDamage 每帧 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 # RecvDamageInfoPacket:damage < 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 Label3D(seam ⑪:无 damagevalue dds 图集 / 逐位特效)。
|
||||
# ProcessDamage:每帧 pop_front 一条。
|
||||
# 40250 官方 1:1(InstanceBaseEffect.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 # 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 ⑪:退化成落字水平抖动符号交替)。
|
||||
|
||||
# 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.8:m_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_BLOCK(world.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
|
||||
|
||||
Reference in New Issue
Block a user