# 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 # 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 贴地) 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 var _fading := {} # vid:int -> true while a culled node tweens out (not re-created until done) 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 # 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() _fading.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))) _by_vid[vid] = node _refresh_shop_sign(node, str(d.get("shop_sign", ""))) _refresh_pvp_tag(vid) _apply_field_updates(node, d) 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", ""))) _apply_field_updates(n, d) # §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. func _apply_field_updates(n: Node3D, d: Dictionary) -> void: var parts: Variant = d.get("parts", []) if parts is Array and parts.size() >= 3: _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_hair", 2, int(parts[2])) 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", "")) func _on_despawn(vid: int) -> void: _duel_opponents.erase(vid) _fading.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 _update_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 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