612 lines
21 KiB
GDScript
612 lines
21 KiB
GDScript
# NetWorld —— 把 M2Client 的网络实体镜像成 Godot 场景节点。
|
||
#
|
||
# 消费 M2Client 的 entity_spawned / entity_despawned / entity_moved /
|
||
# entity_main_set / chat 信号:每个 vid 一个子节点,位置向 client.get_entity(vid).pos
|
||
# 平滑逼近,rotation.y 取自 angle_deg,动画状态取自 func(FUNC_*)。
|
||
#
|
||
# 默认生成「占位胶囊 + 名字牌」(快、无依赖,先跑通同步/插值/状态循环)。
|
||
# 要换成真模型:set_model_factory(func(entity_dict) -> Node3D),返回的节点若有
|
||
# set_anim_state(name:String) 方法就会被驱动。
|
||
#
|
||
# 用法:
|
||
# var nw := preload("res://net_world.gd").new()
|
||
# add_child(nw)
|
||
# nw.setup(m2client, self) # self = 实体挂载父节点(Node3D)
|
||
# nw.world = metin2_world # 可选:贴地
|
||
# nw.main_entity_ready.connect(func(node, vid): _cam.target = node)
|
||
extends Node
|
||
|
||
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 fishing_event(vid: int, subheader: int, dir: int)
|
||
signal motion_event(vid: int, victim_vid: int, motion: int)
|
||
signal vitals(vid: int, hp: int, max_hp: int, dead: bool)
|
||
signal damage_number(vid: int, amount: int, crit: bool, dodge: bool)
|
||
|
||
# CInstanceBase::FUNC_*
|
||
const FUNC_WAIT := 0
|
||
const FUNC_MOVE := 1
|
||
const FUNC_ATTACK := 2
|
||
const FUNC_COMBO := 3
|
||
const FUNC_MOB_SKILL := 4
|
||
const FUNC_EMOTION := 5
|
||
const FUNC_SKILL := 0x80
|
||
|
||
# EDamageFlag
|
||
const DAMAGE_DODGE := 1 << 2
|
||
const DAMAGE_CRITICAL := 1 << 5
|
||
|
||
var client: Node # M2Client
|
||
var parent: Node3D # 实体挂这下面
|
||
var world: Node # Metin2World(可空,用 sample_height 贴地)
|
||
var follow_lerp := 14.0 # 位置逼近速度
|
||
var snap_dist := 6.0 # 超过这么多米直接瞬移(初次出现/大跳)
|
||
|
||
var _by_vid := {} # vid:int -> Node3D
|
||
var _main_vid := 0
|
||
var _local_vid := 0 # 由 net_play 设:这个 vid 由本地 player 代表,不生成节点
|
||
var _local_node: Node3D # 本地玩家的真模型(不挂在 _mount 下)
|
||
var _model_factory: Callable # func(Dictionary) -> Node3D
|
||
var name_resolver: Callable = Callable() # func(entity_dict) -> String(怪名走 mob_proto)
|
||
var _pvp_relations := {} # unordered pair key -> {src_vid,dst_vid,mode}
|
||
var _duel_opponents := {} # server-provided duel opponent VID set
|
||
var _duel_cannot_attack := false
|
||
|
||
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)
|
||
if t in [0, 3, 4, 6]: # 普通/队伍/公会/喊话 -> 头顶气泡
|
||
_bubble(int(v), 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)
|
||
|
||
# 已在局内(重连 / 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
|
||
|
||
# 本地玩家用 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()
|
||
|
||
func set_local_node(node: Node3D) -> void:
|
||
_local_node = node
|
||
|
||
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 _by_vid.has(vid) or vid == _local_vid:
|
||
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)
|
||
node.position = _grounded(d.get("pos", Vector3.ZERO))
|
||
node.set_meta("vid", vid)
|
||
node.set_meta("func", int(d.get("func", FUNC_WAIT)))
|
||
_by_vid[vid] = node
|
||
_refresh_shop_sign(node, str(d.get("shop_sign", "")))
|
||
_refresh_pvp_tag(vid)
|
||
entity_added.emit(node, vid)
|
||
if vid == _main_vid:
|
||
main_entity_ready.emit(node, vid)
|
||
|
||
# GC_CHAR_ADD_INFO 到了:刷新头顶名字 / HP 上限(节点已存在)。
|
||
func _on_info(vid: int, d: Dictionary) -> void:
|
||
var n: Node3D = _by_vid.get(vid, null)
|
||
if n == null:
|
||
return
|
||
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", "")))
|
||
|
||
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", ""))
|
||
|
||
func _on_despawn(vid: int) -> void:
|
||
_duel_opponents.erase(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)
|
||
|
||
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)
|
||
if n.has_node("Label3D"):
|
||
(n.get_node("Label3D") as Label3D).modulate = _state_tint(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)
|
||
|
||
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))
|
||
|
||
# 头顶聊天气泡(~4s 淡出)。本地玩家 vid 走 main_bubble 信号让上层处理。
|
||
signal main_bubble(text: String)
|
||
|
||
func _bubble(vid: int, text: String) -> void:
|
||
if vid == _local_vid or vid == 0:
|
||
main_bubble.emit(text)
|
||
return
|
||
var n: Node3D = _by_vid.get(vid, null)
|
||
if n == null:
|
||
return
|
||
var old := n.get_node_or_null("bubble")
|
||
if old:
|
||
old.queue_free()
|
||
var lbl := Label3D.new()
|
||
lbl.name = "bubble"
|
||
lbl.text = text
|
||
lbl.position.y = 2.35
|
||
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||
lbl.no_depth_test = true
|
||
lbl.pixel_size = 0.0055
|
||
lbl.modulate = Color(1, 1, 1)
|
||
lbl.outline_size = 6
|
||
n.add_child(lbl)
|
||
var tw := create_tween()
|
||
tw.tween_interval(3.0)
|
||
tw.tween_property(lbl, "modulate:a", 0.0, 1.0)
|
||
tw.tween_callback(lbl.queue_free)
|
||
|
||
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)
|
||
if n and n.has_node("HpBar") and max_hp > 0:
|
||
var bar := n.get_node("HpBar") as Sprite3D
|
||
bar.scale.x = clampf(float(hp) / float(max_hp), 0.0, 1.0)
|
||
bar.modulate = Color(0.9, 0.2, 0.2) if not dead else Color(0.3, 0.3, 0.3)
|
||
|
||
func _on_dead(vid: int) -> void:
|
||
var n: Node3D = _by_vid.get(vid, null)
|
||
if n == null:
|
||
return
|
||
# 倒地 + 变灰(服务端随后会发 GC_CHARACTER_DEL 再真正移除)
|
||
var t := create_tween()
|
||
t.tween_property(n, "rotation:x", deg_to_rad(-80.0), 0.3)
|
||
if n.has_method("set_anim_state"):
|
||
n.call("set_anim_state", "dead")
|
||
|
||
# P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体。原客户端在
|
||
# GC_CREATE_FLY 之前可能只收到 FLY_TARGETING(target VID=0 时是坐标),所以
|
||
# 这里也消费 shooter 的目标队列,避免远端技能没有终点而被静默丢弃。
|
||
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 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)
|
||
else:
|
||
var target_cm: Variant = target.get("cm", null)
|
||
if target_cm is Vector2:
|
||
b = _server_cm_to_world(target_cm)
|
||
if a == Vector3.INF or b == Vector3.INF or parent == null:
|
||
return
|
||
var proj := MeshInstance3D.new()
|
||
var sm := SphereMesh.new()
|
||
sm.radius = 0.08
|
||
sm.height = 0.16
|
||
proj.mesh = sm
|
||
var mat := StandardMaterial3D.new()
|
||
mat.albedo_color = Color(1.0, 0.9, 0.4)
|
||
mat.emission_enabled = true
|
||
mat.emission = Color(0.9, 0.7, 0.2)
|
||
proj.material_override = mat
|
||
parent.add_child(proj)
|
||
proj.global_position = a + Vector3(0, 1.0, 0)
|
||
var dur := maxf(0.15, a.distance_to(b) / 40.0)
|
||
var tw := create_tween()
|
||
tw.tween_property(proj, "global_position", b + Vector3(0, 1.0, 0), dur)
|
||
tw.tween_callback(proj.queue_free)
|
||
|
||
# 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)
|
||
|
||
func _on_damage(vid: int, amount: int, flag: int) -> void:
|
||
var crit := (flag & DAMAGE_CRITICAL) != 0
|
||
var dodge := (flag & DAMAGE_DODGE) != 0
|
||
damage_number.emit(vid, amount, crit, dodge)
|
||
var n: Node3D = _by_vid.get(vid, null)
|
||
if n == null or parent == null:
|
||
return
|
||
var lbl := Label3D.new()
|
||
lbl.text = "MISS" if dodge else str(amount)
|
||
lbl.modulate = Color(1.0, 0.85, 0.2) if crit else Color(1, 1, 1)
|
||
lbl.font_size = 48 if crit else 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(randf_range(-0.3, 0.3), 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
|
||
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
|
||
var want := _grounded(e.get("pos", n.position))
|
||
if n.position.distance_to(want) > snap_dist:
|
||
n.position = want
|
||
else:
|
||
n.position = n.position.lerp(want, clampf(follow_lerp * dt, 0.0, 1.0))
|
||
# 朝向:Metin2 heading(度) -> Godot yaw。经验换算,真机再校准。
|
||
var yaw := MapCoord.heading_to_yaw(float(e.get("angle_deg", 0.0)))
|
||
n.rotation.y = lerp_angle(n.rotation.y, yaw, clampf(10.0 * dt, 0.0, 1.0))
|
||
# 动画状态
|
||
var f := int(e.get("func", FUNC_WAIT))
|
||
var moving := bool(e.get("moving", false))
|
||
var walk_mode := int(e.get("walk_mode", 1))
|
||
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)):
|
||
n.set_meta("func", f)
|
||
n.set_meta("moving", moving)
|
||
n.set_meta("walk_mode", walk_mode)
|
||
_apply_anim(n, f, moving, walk_mode)
|
||
|
||
func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1) -> void:
|
||
var state := "wait"
|
||
if f == FUNC_MOVE or (f == FUNC_WAIT and moving):
|
||
state = "walk" if walk_mode == 0 else "run"
|
||
elif f == FUNC_ATTACK or f == FUNC_COMBO:
|
||
state = "attack"
|
||
elif f == FUNC_MOB_SKILL or (f & FUNC_SKILL) != 0:
|
||
state = "skill"
|
||
elif f == FUNC_EMOTION:
|
||
state = "emotion"
|
||
if n.has_method("set_anim_state"):
|
||
n.call("set_anim_state", state)
|
||
elif n.has_node("Label3D"):
|
||
(n.get_node("Label3D") as Label3D).modulate = _state_tint(state)
|
||
|
||
func _state_tint(s: String) -> Color:
|
||
match s:
|
||
"walk": return Color(0.7, 0.9, 1.0)
|
||
"run": return Color(0.6, 1.0, 0.6)
|
||
"attack": return Color(1.0, 0.5, 0.4)
|
||
"skill": return Color(1.0, 0.8, 0.3)
|
||
_: return Color(1, 1, 1)
|
||
|
||
func _grounded(p: Variant) -> Vector3:
|
||
# p 是 M2Client 的网络帧 pos —— 先转到 Metin2World 本地帧,再贴地。
|
||
var v: Vector3 = MapCoord.to_world(p) if p is Vector3 else Vector3.ZERO
|
||
if world and world.has_method("sample_height"):
|
||
v.y = float(world.call("sample_height", v.x, v.z))
|
||
return v
|
||
|
||
func _make_placeholder(d: Dictionary) -> Node3D:
|
||
var root := Node3D.new()
|
||
var mesh := MeshInstance3D.new()
|
||
var cap := CapsuleMesh.new()
|
||
cap.radius = 0.35
|
||
cap.height = 1.8
|
||
mesh.mesh = cap
|
||
mesh.position.y = 0.9
|
||
var mat := StandardMaterial3D.new()
|
||
var is_main := bool(d.get("is_main", false))
|
||
mat.albedo_color = Color(0.35, 0.6, 1.0) if is_main else Color(0.85, 0.4, 0.35)
|
||
mesh.material_override = mat
|
||
root.add_child(mesh)
|
||
_attach_nameplate(root, d)
|
||
return root
|
||
|
||
# 头顶名字 Label3D + HP 条 Sprite3D(占位胶囊和真模型共用)。
|
||
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 = 2.1
|
||
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||
tag.no_depth_test = true
|
||
tag.pixel_size = 0.006
|
||
root.add_child(tag)
|
||
var bar := Sprite3D.new()
|
||
bar.name = "HpBar"
|
||
bar.texture = _white_1px()
|
||
bar.pixel_size = 0.01 # 100px -> 1m
|
||
bar.position.y = 1.95
|
||
bar.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||
bar.no_depth_test = true
|
||
bar.modulate = Color(0.9, 0.2, 0.2)
|
||
bar.scale.x = 1.0
|
||
root.add_child(bar)
|
||
|
||
static var _white_tex: Texture2D
|
||
static func _white_1px() -> Texture2D:
|
||
if _white_tex == null:
|
||
var img := Image.create(100, 6, false, Image.FORMAT_RGBA8)
|
||
img.fill(Color.WHITE)
|
||
_white_tex = ImageTexture.create_from_image(img)
|
||
return _white_tex
|