Files
mtgodot-poc/project/net_world.gd
T
shenleiandClaude Sonnet 5 3487df2a16 40250 classic W2: EntityStore data-layer only + net_world visibility (增量 50)
CLIENT-GAP-FIX §2.1/§2.3/§2.4/§2.5 (+ §2.2 steps 4-5):

- §2.1 two-layer model: EntityStore is data-only. Entries are created only by
  mut_spawn / mut_spawn_full / mut_spawn_main and removed only by mut_despawn /
  main-vid table clear. Every other mut_* now does find-or-return with a
  MT_NET_TRACE-gated debug log; no pending / replay buffer. Visibility moves to
  net_world.gd::_update_visibility() — per frame it walks client.get_entities()
  and mirrors is_main / AFFECT_SHOW_ALWAYS(bit 7) / IsWall(14201-14204) /
  planar-distance < CHAR_STAGE_VIEW_BOUND (200*100 cm), building a node on entry
  and fading + freeing it past +10 while keeping the data row (_fading guard).
  Gate is fail-open when the data layer is not queryable (no get_entities /
  _main_vid == 0 / no pos_cm) so existing fake-client tests are unaffected.
- §2.3 one overwrite rule: mut_spawn_full() does a clean whole-row value replace
  (m_ents[vid] = e), no preservation of prior move state on VID reuse; mut_spawn()
  now delegates to it. net_world._on_spawn destroys + recreates a node when the
  vid already has one; mount/dismount rebuild keeps the old global_position.
- §2.4 points bounds: mut_set_points() clamps counts > POINT_MAX_NUM (255) and
  zeroes the tail of a short snapshot; mut_set_point() rejects type >= 255.
- §2.5 field apply order + affect flags: new EntityStore::mut_affect_flags(vid,
  lo, hi) assembles v = lo | (hi << 32) and queues a dirty tick; mut_char_update
  guild/alignment/pk changes now also push Info. net_world._apply_field_updates()
  applies fields in the reference call order (NetworkActorManager.cpp:470).
- §2.2 steps 4-5: mut_char_info() / mut_shop_sign() drop the touch()-creates-
  entity path. Steps 1-3 (m_pending_actor two-packet merge) remain W1-owned.

Tests: ./build.sh Debug exit 0; ctest 16/16 (net.entity_store gains a §2.1
data-layer assertion block). New project/net_world_vis_test.gd for scene-node
visibility. GDScript regressions green: netbridge_test, gamescene_test,
netplay_test, p9_test, combat_fx_test, player_motion_test, net_world_vis_test.

Follow-up for W1: call mut_affect_flags(vid, affect_flag[0], affect_flag[1])
from classic_parser.cpp after GC_CHARACTER_ADD[2] / GC_CHARACTER_UPDATE
(mut_char_update has no affect param). wire_classic.h already has the wanted
static_assert on GCPoints.points.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
2026-09-02 16:06:06 +09:00

793 lines
29 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# NetWorld —— 把 M2Client 的网络实体镜像成 Godot 场景节点。
#
# 消费 M2Client 的 entity_spawned / entity_despawned / entity_moved /
# entity_main_set / chat 信号:每个 vid 一个子节点,位置向 client.get_entity(vid).pos
# 平滑逼近,rotation.y 取自 angle_deg,动画状态取自 funcFUNC_*)。
#
# 默认生成「占位胶囊 + 名字牌」(快、无依赖,先跑通同步/插值/状态循环)。
# 要换成真模型:set_model_factory(func(entity_dict) -> Node3D),返回的节点若有
# set_anim_state(name:String) 方法就会被驱动。
#
# 用法:
# var nw := preload("res://net_world.gd").new()
# add_child(nw)
# nw.setup(m2client, self) # self = 实体挂载父节点(Node3D)
# nw.world = metin2_world # 可选:贴地
# nw.main_entity_ready.connect(func(node, vid): _cam.target = node)
extends Node
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_TARGETINGtarget 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