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
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
f32064c74a
commit
3487df2a16
+168
-2
@@ -39,6 +39,22 @@ const FUNC_SKILL := 0x80
|
||||
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 贴地)
|
||||
@@ -54,6 +70,7 @@ var name_resolver: Callable = Callable() # func(entity_dict) -> String(怪名
|
||||
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
|
||||
@@ -127,6 +144,7 @@ func clear_for_map_change() -> void:
|
||||
node.queue_free()
|
||||
entity_removed.emit(int(vid))
|
||||
_by_vid.clear()
|
||||
_fading.clear()
|
||||
_main_vid = 0
|
||||
_pvp_relations.clear()
|
||||
_duel_opponents.clear()
|
||||
@@ -142,7 +160,31 @@ func main_node() -> Node3D:
|
||||
|
||||
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:
|
||||
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():
|
||||
@@ -153,12 +195,17 @@ func _on_spawn(d: Dictionary) -> void:
|
||||
_attach_nameplate(node, d) # 真模型也要头顶名字 + HP 条
|
||||
node.name = "e_%d" % vid
|
||||
parent.add_child(node)
|
||||
node.position = _grounded(d.get("pos", Vector3.ZERO))
|
||||
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)
|
||||
@@ -175,6 +222,123 @@ func _on_info(vid: int, d: Dictionary) -> void:
|
||||
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
|
||||
@@ -204,6 +368,7 @@ func _display_name(d: Dictionary) -> String:
|
||||
|
||||
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)
|
||||
@@ -522,6 +687,7 @@ func _on_damage(vid: int, amount: int, flag: int) -> void:
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user